From c5905a5b1e24e4099d95eef3221b6bd4d3df34d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=AE=A9=E5=AE=87?= Date: Fri, 11 Sep 2026 00:40:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=9C=AC=E4=BB=93=E5=8D=87?= =?UTF-8?q?=E4=B8=BA=E5=94=AF=E4=B8=80=E6=BA=90=EF=BC=88=E5=8E=9F=20sap-cl?= =?UTF-8?q?i=20=E6=BA=90=E7=A0=81=E4=BB=93=E5=BD=92=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方向反转:此前 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 重写为单源开发流程。 --- .claude/rules/abap-coding-rules.md | 120 ++ .claude/rules/error-handling.md | 121 ++ .claude/rules/sap-tool-constraints.md | 135 ++ .claude/settings.json | 57 + .editorconfig | 19 + .github/workflows/ci.yml | 41 + .gitignore | 43 +- .hermes/include-uri-fix-spec.md | 89 + .pre-commit-config.yaml | 45 + CLAUDE.md | 130 ++ README.md | 13 + assets/pyproject.toml | 10 +- docs/NW740-COMPATIBILITY.md | 72 + docs/OPERATION_MANUAL.md | 689 ++++++ docs/RELEASING.md | 97 + docs/adt/01.ADT架构与通信原理.md | 215 ++ docs/adt/02.查询代码-原理.md | 198 ++ docs/adt/03.修改代码-原理.md | 257 +++ docs/adt/04.检查代码-原理.md | 270 +++ docs/adt/05.提交与传输-原理.md | 317 +++ docs/adt/06.ADT测试代码.md | 336 +++ docs/adt/07.ADT代码同步工具.md | 352 +++ docs/adt/README.md | 92 + docs/assets/architecture.drawio | 115 + docs/assets/architecture.drawio.png | Bin 0 -> 524300 bytes docs/dev/AGENTS.md | 97 + docs/dev/CLAUDE.md | 180 ++ docs/dev/adt-endpoint-reference.md | 51 + docs/dev/architecture.md | 379 ++++ docs/dev/batch-design.md | 684 ++++++ docs/dev/extension-plan.md | 530 +++++ docs/dev/test-coverage-report.md | 103 + docs/dev/test-plan.md | 344 +++ docs/dev/test-report.md | 241 +++ docs/guide.md | 712 ++++++ docs/功能矩阵分析.md | 192 ++ docs/差距分析报告.md | 202 ++ .../.openspec.yaml | 4 + .../design.md | 32 + .../proposal.md | 30 + .../specs/object-lifecycle/spec.md | 61 + .../tasks.md | 34 + .../2026-06-09-base-batch/.openspec.yaml | 4 + .../archive/2026-06-09-base-batch/proposal.md | 27 + .../specs/batch-ops/spec.md | 89 + .../archive/2026-06-09-base-batch/tasks.md | 47 + .../2026-06-09-base-core/.openspec.yaml | 4 + .../archive/2026-06-09-base-core/proposal.md | 31 + .../specs/connection/spec.md | 45 + .../specs/object-lifecycle/spec.md | 93 + .../archive/2026-06-09-base-core/tasks.md | 69 + .../.openspec.yaml | 4 + .../proposal.md | 29 + .../specs/config/spec.md | 37 + .../specs/connection/spec.md | 17 + .../specs/search-browse/spec.md | 13 + .../tasks.md | 24 + .../.openspec.yaml | 4 + .../proposal.md | 26 + .../specs/search-browse/spec.md | 61 + .../specs/transport/spec.md | 33 + .../tasks.md | 36 + .../.openspec.yaml | 4 + .../proposal.md | 24 + .../specs/quality/spec.md | 41 + .../tasks.md | 26 + .../.openspec.yaml | 4 + .../proposal.md | 19 + .../specs/object-lifecycle/spec.md | 47 + .../tasks.md | 44 + openspec/config.yaml | 25 + openspec/spec-coverage-report.md | 234 ++ openspec/specs/batch-ops/spec.md | 83 + openspec/specs/config/spec.md | 47 + openspec/specs/connection/spec.md | 55 + openspec/specs/object-lifecycle/spec.md | 186 ++ openspec/specs/quality/spec.md | 47 + openspec/specs/search-browse/spec.md | 79 + openspec/specs/transport/spec.md | 39 + references/sap-tool-constraints.md | 4 +- .../classes/zcl_sapilot_constants.abap | 98 + .../classes/zcl_sapilot_http_handler.abap | 205 ++ .../classes/zcl_sapilot_op_admin.abap | 382 ++++ .../functions/zgroup/z_my_func.abap | 5 + tests/e2e_project/manifest.json | 70 + tests/test_e2e.md | 332 +++ tests/test_sapcli.py | 708 ++++++ tests/unit/__init__.py | 0 tests/unit/test_activate.py | 169 ++ tests/unit/test_app_config.py | 327 +++ tests/unit/test_batch_analyze.py | 733 +++++++ tests/unit/test_cli.py | 384 ++++ tests/unit/test_client.py | 1176 ++++++++++ tests/unit/test_clone.py | 322 +++ tests/unit/test_commands.py | 600 ++++++ tests/unit/test_commands_extra.py | 1901 +++++++++++++++++ tests/unit/test_crud_extra.py | 1120 ++++++++++ tests/unit/test_ddic.py | 1076 ++++++++++ tests/unit/test_enhancement.py | 171 ++ tests/unit/test_history.py | 139 ++ tests/unit/test_modules.py | 898 ++++++++ tests/unit/test_program_run.py | 105 + tests/unit/test_repo_guards.py | 217 ++ tests/unit/test_unit_test.py | 206 ++ 104 files changed, 20744 insertions(+), 10 deletions(-) create mode 100644 .claude/rules/abap-coding-rules.md create mode 100644 .claude/rules/error-handling.md create mode 100644 .claude/rules/sap-tool-constraints.md create mode 100644 .claude/settings.json create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .hermes/include-uri-fix-spec.md create mode 100644 .pre-commit-config.yaml create mode 100644 CLAUDE.md create mode 100644 docs/NW740-COMPATIBILITY.md create mode 100644 docs/OPERATION_MANUAL.md create mode 100644 docs/RELEASING.md create mode 100644 docs/adt/01.ADT架构与通信原理.md create mode 100644 docs/adt/02.查询代码-原理.md create mode 100644 docs/adt/03.修改代码-原理.md create mode 100644 docs/adt/04.检查代码-原理.md create mode 100644 docs/adt/05.提交与传输-原理.md create mode 100644 docs/adt/06.ADT测试代码.md create mode 100644 docs/adt/07.ADT代码同步工具.md create mode 100644 docs/adt/README.md create mode 100644 docs/assets/architecture.drawio create mode 100644 docs/assets/architecture.drawio.png create mode 100644 docs/dev/AGENTS.md create mode 100644 docs/dev/CLAUDE.md create mode 100644 docs/dev/adt-endpoint-reference.md create mode 100644 docs/dev/architecture.md create mode 100644 docs/dev/batch-design.md create mode 100644 docs/dev/extension-plan.md create mode 100644 docs/dev/test-coverage-report.md create mode 100644 docs/dev/test-plan.md create mode 100644 docs/dev/test-report.md create mode 100644 docs/guide.md create mode 100644 docs/功能矩阵分析.md create mode 100644 docs/差距分析报告.md create mode 100644 openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md create mode 100644 openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md create mode 100644 openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-base-batch/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md create mode 100644 openspec/changes/archive/2026-06-09-base-batch/tasks.md create mode 100644 openspec/changes/archive/2026-06-09-base-core/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-base-core/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md create mode 100644 openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-06-09-base-core/tasks.md create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md create mode 100644 openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md create mode 100644 openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md create mode 100644 openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md create mode 100644 openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md create mode 100644 openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md create mode 100644 openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/spec-coverage-report.md create mode 100644 openspec/specs/batch-ops/spec.md create mode 100644 openspec/specs/config/spec.md create mode 100644 openspec/specs/connection/spec.md create mode 100644 openspec/specs/object-lifecycle/spec.md create mode 100644 openspec/specs/quality/spec.md create mode 100644 openspec/specs/search-browse/spec.md create mode 100644 openspec/specs/transport/spec.md create mode 100644 tests/e2e_project/classes/zcl_sapilot_constants.abap create mode 100644 tests/e2e_project/classes/zcl_sapilot_http_handler.abap create mode 100644 tests/e2e_project/classes/zcl_sapilot_op_admin.abap create mode 100644 tests/e2e_project/functions/zgroup/z_my_func.abap create mode 100644 tests/e2e_project/manifest.json create mode 100644 tests/test_e2e.md create mode 100644 tests/test_sapcli.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_activate.py create mode 100644 tests/unit/test_app_config.py create mode 100644 tests/unit/test_batch_analyze.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_client.py create mode 100644 tests/unit/test_clone.py create mode 100644 tests/unit/test_commands.py create mode 100644 tests/unit/test_commands_extra.py create mode 100644 tests/unit/test_crud_extra.py create mode 100644 tests/unit/test_ddic.py create mode 100644 tests/unit/test_enhancement.py create mode 100644 tests/unit/test_history.py create mode 100644 tests/unit/test_modules.py create mode 100644 tests/unit/test_program_run.py create mode 100644 tests/unit/test_repo_guards.py create mode 100644 tests/unit/test_unit_test.py diff --git a/.claude/rules/abap-coding-rules.md b/.claude/rules/abap-coding-rules.md new file mode 100644 index 0000000..c7787d4 --- /dev/null +++ b/.claude/rules/abap-coding-rules.md @@ -0,0 +1,120 @@ +# ABAP 编码规则(质检标准) + +> 维护者:Rangyu | 版本:v1.0 | 2026-08-05 +> +> 本规则文档由 **Rangyu(老板)维护**,质检员在代码审计时以此文档为唯一标准。 +> 质检结果分为:✅合规 / ❌违规(必须修复) / ⚠️建议(推荐修复) + +--- + +## 1. 安全规则(Security) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| S01 | 生产代码中**禁止硬断点**(BREAK-POINT) | ❌违反 | `BREAK-POINT.` → 删除 | +| S02 | 关键数据访问前**必须 AUTHORITY-CHECK** | ❌违反 | 聊天记录读取、配置维护、表维护 | +| S03 | SQL 注入防护:**所有变量必须用 @ 转义** | ✅合规 | `WHERE field = @lv_var` | +| S04 | 敏感数据(聊天内容、用户信息)需脱敏或审计 | ⚠️建议 | `zidt_chatlog` message_body 字段 | +| S05 | | | | +| S06 | | | | +| S07 | | | | + +*(Rangyu 补充更多安全规则...)* + +--- + +## 2. 性能规则(Performance) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| P01 | 禁止 `SELECT *`,仅查询所需字段 | ⚠️建议 | `SELECT field1, field2 FROM ...` | +| P02 | 禁止无 WHERE 条件的全表扫描(小配置表除外) | ⚠️建议 | `SELECT ... FROM ztable WHERE ...` | +| P03 | 大表查询必须有索引支撑 | ⚠️建议 | SE11 检查索引 | +| P04 | 循环内禁止 SQL 查询 | ❌违反 | LOOP 内 SELECT | +| P05 | 循环字符串拼接用 `CONCATENATE` 单次操作 | ⚠️建议 | 避免 LOOP 内 `&&` | +| P06 | | | | +| P07 | | | | + +*(Rangyu 补充更多性能规则...)* + +--- + +## 3. 可维护性规则(Maintainability) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| M01 | 重复代码超过 80% 相似 → 提取公共方法/FORM | ❌违反 | 3处 AI 调用重复约 90% | +| M02 | 每个 FORM/方法不超过 200 行 | ⚠️建议 | | +| M03 | 禁止**裸硬编码字符串**(模型名、GUI状态、事务码) | ⚠️建议 | 用常量 `CONSTANTS c_model TYPE ... VALUE 'qwq-32b'` | +| M04 | 关键逻辑必须有注释 | ⚠️建议 | | +| M05 | 无用变量/死代码必须清理 | ⚠️建议 | `gs_tool-END` 赋值未消费 | +| M06 | | | | +| M07 | | | | + +*(Rangyu 补充更多可维护性规则...)* + +--- + +## 4. 错误处理规则(Error Handling) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| E01 | `sy-subrc <> 0` 后**不能静默处理** | ❌违反 | `IF sy-subrc <> 0. " 错误处理 ENDIF.` → 必须 MESSAGE/EXIT | +| E02 | API 调用必须有异常保护(CATCH) | ⚠️建议 | HTTP 调用、RFC 调用 | +| E03 | 数据库操作必须有错误处理 | ⚠️建议 | COMMIT/ROLLBACK | +| E04 | **CALL FUNCTION 前必须核实被调对象真实存在**:ADT 通道无法校验被调函数与引用结构是否存在(syntax-check 只覆盖当前对象自身),凡调用 `CALL FUNCTION 'Z...'` 或引用 `Z...` 结构/表类型,必须先用 sap-cli(`info`/`download`/`read-table DD02L` 任一)确认对象存在且名称逐字符一致,再落代码 | ❌违反 | 凭记忆写 `CALL FUNCTION 'ZMM_BIP_CREATE'`(实际对象是 `ZMM_BIP_VENDOR_CREATE`)→ 编译期不报错、运行期 CALL_FUNCTION_NOT_FOUND 转储。正确:先 `sap-cli info --name 组名/函数名 --type function` 查实,再写调用 | +| E05 | **CALL FUNCTION 的参数结构必须与被调函数真实签名一致**:IMPORTING/EXPORTING/CHANGING/TABLES 各参数名、类型、顺序以核实到的函数签名为准(info 输出或 download 函数源码的 Interface 节),不得按猜测拼参数;引用的 DDIC 结构(如 `TYPE zmm_bip_001_in`)须同批核实结构存在且组件名/类型一致 | ❌违反 | `CALL FUNCTION 'X' EXPORTING iv_a = lv` 但实际签名参数是 `IS_IMPORT` → 运行期 PARAMETER_NOT_FOUND/类型不匹配转储。正确:以 `download` 拿到函数头 `"*" Local Interface: 注释节为准逐参数比对,结构组件以 DD02L/DD03L 实查为准 | + +*(Rangyu 补充更多错误处理规则...)* + +> **E04/E05 背景(2026-09-10 老板指令)**:远程语法检查不校验被调函数的存在性与签名匹配——本地语法全绿 ≠ 运行期不转储。开发报表/程序中所有 `CALL FUNCTION`(尤其调用本组外/客户化 Z 对象)一律先核实后调用,核实记录(对象存在性 + 签名比对)纳入交付物。 + +--- + +## 5. NW 7.40 兼容规则(7.40 Compatibility) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| C01 | **禁止 `DATA(...)` inline 声明** | ❌违反 | `DATA(lv_x) = ...` → `DATA lv_x TYPE ...` | +| C02 | Open SQL `@` 转义**全局一致** | ❌违反 | 不能混用 `@lv` 和 `lv` | +| C03 | 禁止字符串模板 `\|...\|` | ❌违反 | 用 `CONCATENATE` 代替 | +| C04 | 禁止 `WITH EMPTY KEY` | ❌违反 | 用 `WITH DEFAULT KEY` | +| C05 | LOOP AT SCREEN 逻辑避免重复 | ⚠️建议 | 提取公共 FORM | +| C06 | | | | +| C07 | | | | + +*(Rangyu 补充更多 7.40 兼容规则...)* + +--- + +## 6. 代码风格规则(Style) + +| ID | 规则 | 判定 | 示例 | +|----|------|------|------| +| T01 | 命名遵循 SAP 规范(Z开头自定义) | ✅合规 | `ZIDTR_AI_ASSISTANT` | +| T02 | 变量命名语义化 | ⚠️建议 | `lv_date` 优于 `lv_d` | +| T03 | 注释语言统一(中/英) | ⚠️建议 | | +| T04 | | | | +| T05 | | | | + +*(Rangyu 补充更多风格规则...)* + +--- + +## 附录:今天质检发现的典型违规 + +| 程序 | 问题 | 规则ID | 行号 | +|------|------|--------|------| +| ZIDTR_AI_ASSISTANT | 生产代码 BREAK-POINT | S01 | L44 | +| ZIDTR_AI_ASSISTANT | 权限检查缺失 | S02 | 全文 | +| ZIDTR_AI_ASSISTANT | 3处代码重复~90% | M01 | L464-667 | +| ZIDTR_AI_ASSISTANT | 硬编码模型名 | M03 | L207 | +| ZIDTR_AI_ASSISTANT | F4错误静默处理 | E01 | L170-172 | +| ZIDTR_AI_ASSISTANT | DATA(...) inline | C01 | L152等6处 | +| ZIDTR_AI_ASSISTANT | @转义混用 | C02 | L154/259 | + +--- + +> **使用方式**:质检员读取本文件 → 逐条对照代码 → 输出 `✅/❌/⚠️` 审计报告。 +> +> **维护方式**:Rangyu 随时增删规则,更新后通知小迅同步到质检员的审查标准。 diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md new file mode 100644 index 0000000..f2a71c0 --- /dev/null +++ b/.claude/rules/error-handling.md @@ -0,0 +1,121 @@ +# 错误处理流程 + +sap-cli 命令失败时的标准处理流程。严格按顺序执行。 + +## 通用流程 + +``` +命令失败 + ↓ +1. 诊断根因(使用方式错误 or 代码/环境问题?) + ↓ +2. 使用方式错误 → 修正参数后重试 + ↓ +3. sap-cli 限制 → 报告失败,不绕过 + ↓ +4. SAP 系统限制 → 穷尽自动化方案 + ↓ +5. 确认无法自动化 → 请求用户 SAP GUI 操作 +``` + +## 步骤 1:诊断根因 + +检查以下项目: + +| 检查项 | 方法 | +|--------|------| +| 命令参数是否正确 | 对照 `python main.py --help` 和 SKILL.md 验证参数 | +| 文件是否存在、内容是否为空 | `cat` 或 `ls` 检查 | +| 传输请求号是否正确 | `python main.py transport info --corr_nr DEVK901XXX` | +| 网络连接是否正常 | `python main.py config show` 验证配置 | +| 对象是否存在于 SAP | `python main.py info --name XXX --type class` | + +## 步骤 2:使用方式错误 + +修正后重试。常见修正: + +- 缺少 `--corr_nr` → 加上传输请求号 +- 对象名大小写错误 → sap-cli 会自动处理,但路径要注意 +- 文件路径错误 → 检查相对/绝对路径 +- 对象类型不匹配 → 用 `python main.py --help` 确认支持的类型 + +## 步骤 3:sap-cli 限制 + +如果确认不是使用方式问题,**如实报告错误信息**,不要尝试绕过 sap-cli: + +``` +❌ 错误做法:写 Python requests 脚本绕过 +❌ 错误做法:用 curl 直接调 ADT 端点 +❌ 错误做法:导入 ADTClient 调内部方法 +✅ 正确做法:报告失败,说明原因 +``` + +## 步骤 4:穷尽自动化方案(SAP 系统限制时) + +遇到 SAP 系统限制时,按以下顺序穷尽自动化方案: + +### 方案 A:调整 sap-cli 参数重试 + +- 尝试不同的 `--corr_nr` +- 尝试 `--dry-run` 预览后执行 +- 检查是否有残留锁(`show-table` 查看 SM12 相关信息) + +### 方案 B:ABAP 报表 workaround + +通过 `run-program` 执行辅助报表: + +```bash +# 清除残留锁 +python main.py sync --type report --name ZSAPILOT_CLEAR_LOCKS --path --corr_nr DEVK901XXX +python main.py run-program --name ZSAPILOT_CLEAR_LOCKS +``` + +### 方案 C:delete + recreate + +如果对象需要完全重写: + +```bash +python main.py delete --name ZMY_CLASS --type class +python main.py sync --name ZMY_CLASS --type class --path --corr_nr DEVK901XXX +``` + +注意:delete 可能因传输请求绑定而失败(HTTP 423),此时需要用户在 SE09 操作。 + +### 方案 D:activate 单独重试 + +```bash +python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX +``` + +NW 7.40 的 double-activate 已内置在 sap-cli 中。如果 activate 仍然失败,**不要再尝试其他激活策略**,直接报告给用户。 + +## 步骤 5:请求用户 SAP GUI 操作 + +**只在确认所有自动化方案穷尽后**才请求用户操作。 + +### 必须一次性给出完整操作清单 + +当需要用户在 SAP GUI 操作时,**绝不能渐进式发现问题**: + +- ❌ 错误:先让用户加字段 A,测试后发现缺字段 B,又让用户加字段 B +- ✅ 正确:先做全面对比分析(`show-table` vs 代码中的字段引用),一次性给出完整变更清单 + +### 系统表操作的 4 步授权流程 + +如果需要操作系统表(唯一的例外情况): + +1. **解释原因** — 为什么需要操作系统表 +2. **列出具体操作** — 表名 + 操作类型(如 `DELETE FROM SEOCLASS WHERE ...`) +3. **说明风险和替代方案** — 可能的副作用、有没有不操作系统表的替代方案 +4. **等待用户确认** — 得到明确授权后才执行 + +## 常见失败场景速查 + +| 错误 | 常见原因 | 处理方式 | +|------|----------|----------| +| HTTP 406 (Lock DDIC) | NW 7.40 不支持 DDIC 对象 ADT Lock | 报告用户,SE09 处理 | +| HTTP 403 (Locked) | 残留 enqueue lock | 先尝试 `run-program` 清锁报表,不行才 SM12 | +| HTTP 400 (SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 检查本地 vs SAP 的 DEFINITION 差异 | +| HTTP 423 (Transport lock) | 对象绑定在传输请求中 | 报告用户,SE09 处理 | +| 激活失败仍 inactive | NW 7.40 ADT 限制 | sap-cli 已内置 double-activate,仍失败则报告用户去 SE09 | +| `WITH EMPTY KEY` 运行 dump | NW 7.40 不支持 | 修改为 `WITH NON-UNIQUE KEY` 或 `WITH DEFAULT KEY` | diff --git a/.claude/rules/sap-tool-constraints.md b/.claude/rules/sap-tool-constraints.md new file mode 100644 index 0000000..53e5255 --- /dev/null +++ b/.claude/rules/sap-tool-constraints.md @@ -0,0 +1,135 @@ +# sap-cli 使用约束 + +本文件定义使用 sap-cli 操作 SAP 系统时的 **硬性约束**。违反任何一条都会被 Hooks 拦截。 + +## 🚫 规则 1:只使用 sap-cli 命令操作 SAP 系统 + +操作 SAP 系统时,**必须且只能**使用 `python main.py <命令>` 的形式。 + +**严禁以下行为**: + +| 禁止操作 | 原因 | +|----------|------| +| 用 Python `requests` / `urllib` 直接调 ADT REST 端点 | 必须通过 sap-cli 命令 | +| 用 curl 直接调 ADT 端点(`/sap/bc/adt/*`) | 必须通过 sap-cli 命令 | +| 用 curl 调 SOAP RFC(`/sap/bc/soap/rfc`) | 必须通过 sap-cli 的 `read-table` 命令 | +| 编写 Python 脚本导入 `sapcli.client.ADTClient` 后直接调内部方法 | sap-cli 的内部 API 不是公开接口 | +| 在 Python 脚本中手动构造 ADT XML body | 必须通过 sap-cli 命令处理 | +| 用 `python -c` 内联调用 requests 访问 SAP | 同上 | + +**唯一合法的 SAP 交互方式**: + +```bash +cd D:/Gitee/sap-cli-skill/assets && python main.py [options] +``` + +> 本机唯一源目录为 `D:/Gitee/sap-cli-skill`,工具在其 `assets/` 下(原 `D:/Gitee/sap-cli` 已归档,勿再用)。通过 `--profile <名>` 切换目标系统,连接配置在 `assets/config.ini`。 + +### 例外(仅在以下情况下允许) + +- `run-program` 执行自定义 ABAP 报表 — 这是 sap-cli 的内置功能 +- 用户**明确要求**使用其他方式(如"请用 curl 测试这个端点") +- **开发 sap-cli 本身**(作为项目开发而非使用工具时) + +## 🚫 规则 2:禁止修改 SAP 标准开发对象 + +SAP 系统中的标准交付对象(`CL_*`、`CX_*`、`IF_*`、`SAPL*` 等)**严禁修改**。 + +| 禁止 | 说明 | +|------|------| +| `download` 标准对象后修改再 `sync` 回去 | 破坏 SAP 系统一致性 | +| 对标准对象执行 `delete` | 不可逆操作 | +| `run-program` 修改标准对象源码 | 同上 | + +**允许**(只读): + +- ✅ `download` / `info` / `list` / `search` / `whereused` +- ✅ `show-table` / `read-table` — 查看结构和数据 +- ✅ `diff` — 对比分析 + +**判定标准**:对象 original system 不是用户自己的开发系统,或对象名以 SAP 标准命名空间开头。**不确定时先问用户**。 + +## 🚫 规则 3:禁止操作系统表数据 + +SAP 系统表(存储元数据、运行时状态、内部配置的表)**严禁直接操作**。 + +| 禁止 | 典型表 | +|------|--------| +| INSERT / UPDATE / DELETE / MODIFY 系统表 | TADIR, E071, SEOCLASS, REPOSRC, D010SINF, T000, TDEVC, TSTC | + +**允许**(只读): + +- ✅ `read-table` / `show-table` 查看(SELECT)系统表数据用于诊断 +- ✅ `run-program` 报表中 SELECT 读取系统表 + +**唯一例外**:用户**明确授权**后才能操作系统表,且必须遵循 4 步授权流程(见 error-handling.md)。 + +## 🚫 规则 4:sync 命令必须带 --corr_nr + +```bash +# ✅ 正确 +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy.abap --corr_nr DEVK901362 + +# ❌ 错误(缺少 --corr_nr,会触发交互式传输请求选择) +python main.py sync --name ZMY_CLASS --type class --path ./src/zmy.abap +``` + +## 🚫 规则 5:DDIC 对象的 NW 7.40 限制 + +在 NW 7.40 上,DDIC 对象(domain、dataelement、table)的 ADT Lock 返回 HTTP 406。 + +- 这是 **SAP 系统限制**,不是 sap-cli 的 bug +- sync DDIC 失败时,不要反复重试 — 报告给用户,让用户通过 SAP GUI SE09 处理 +- 新建的 DDIC 对象(不存在于 SAP)可通过 `create --definition` 创建 + +## 🚫 规则 6:查表数据用 read-table,查表结构用 show-table + +sap-cli 已内置这些命令,不要自己写 HTTP 调用。 + +```bash +# 查看表结构 +python main.py show-table --name ZSAPILOT_OBJ + +# 查看表数据 +python main.py read-table --name ZSAPILOT_OBJ --max-rows 50 +``` + +## 🚫 规则 7:DDIC 未完成禁止开发引用它的代码(依赖顺序) + +代码引用的每个 DDIC 对象(域/数据元素/表/结构/表类型)必须先完成「上传 → 语法检查通过 → 激活成功」三关,才能开始写引用它的代码。 + +- 落地顺序:`domain → dataelement → table/structure → tabletype → interface → class → function → report` +- 是否完成**以实测为准**:结构查 `DD02L`(`AS4LOCAL = 'A'`)、表类型查 `DD40L`、其余查 `info` / `TADIR`;**不采信会话自述** +- 违反后果:后续每个引用对象语法检查报「类型/结构 XXX 未知」,整批返工 + +## 🚫 规则 8:强制开发闭环(不得跳步) + +``` +1. 本地编写/修改源码 → 2. 同步 SAP(upload/sync) → 3. 语法检查(syntax-check) + ↑ │ + └──────────────── 有错则回到第 1 步 ←───────────────────┘ +4. 语法检查 0 错误 → 激活(activate) → 5. 激活成功后才允许执行测试(run-program/unit-test) + └── 测试不过 → 回到第 1 步 +``` + +禁止: + +- ❌ 跳过语法检查直接 activate +- ❌ 未激活就 run-program / unit-test(跑旧版本或报对象不存在) +- ❌ 把「语法检查通过」当「功能正常」—— syntax-check 不校验被调对象存在性与签名(见 `abap-coding-rules.md` E04/E05) +- ❌ 把「S4T 演示机通过」表述为「CEM/生产验证通过」 +- ❌ 多会话并发操作同一批对象(派下一会话前先确认前一个已退出) + +## 自检清单 + +每次执行 SAP 操作前,确认: + +1. ✅ 我在用 `python main.py <命令>` 吗? +2. ✅ 我没有直接调 ADT/SOAP/RFC 端点吗? +3. ✅ 我操作的是 Z* 开头的自定义对象吗? +4. ✅ 我没有 INSERT/UPDATE/DELETE 系统表吗? +5. ✅ sync 命令带了 `--corr_nr` 吗? +6. ✅ 我引用的 DDIC 对象都已激活(实测确认)了吗? +7. ✅ 我按「本地改 → 同步 → 语法检查 → 循环至 0 错误 → 激活 → 测试」执行了吗? +8. ✅ 我核实过被调函数/结构的存在性与签名了吗(E04/E05)? +9. ✅ 同一任务只有一个开发者会话在跑吗? diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2184ee9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,57 @@ +{ + "permissions": { + "deny": [ + "Bash(curl *sap/bc/adt*)", + "Bash(curl *sap/bc/soap*)", + "Bash(curl *sap/bc/webdynpro*)", + "Bash(python -c *requests*sap/bc*)", + "Bash(python -c *urllib*sap/bc*)" + ], + "allow": [ + "Bash(python main.py *)", + "Bash(python -m sapcli *)", + "Bash(python -m unittest *)", + "Bash(python -m pytest *)", + "Bash(python -m json.tool)", + "Bash(git *)", + "Bash(cat *)", + "Bash(ls *)", + "Bash(python tests/test_e2e.py*)", + "Read", + "Edit", + "Write" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'curl.*(sap/bc/adt|sap/bc/soap|sap/bc/webdynpro)'; then echo '⛔ BLOCKED: Direct ADT/SOAP/WebDynpro access is prohibited. Use sap-cli commands (python main.py ).' && exit 2; fi" + }, + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'python.*-c.*(requests|urllib).*sap.*/bc/'; then echo '⛔ BLOCKED: Direct HTTP requests to SAP are prohibited. Use sap-cli commands (python main.py ).' && exit 2; fi" + }, + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qiE 'from sapcli.client import|from sapcli.client._'; then echo '⛔ BLOCKED: Importing sap-cli internal modules is prohibited for SAP operations. Use CLI commands (python main.py ).' && exit 2; fi" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash(python main.py sync*)", + "hooks": [ + { + "type": "command", + "command": "echo '📋 Sync completed. Check output for activation status. If activation failed, try: python main.py activate --name --type --corr_nr '" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e55cf87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig: https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,toml,cfg,ini}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d999740 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# 注意:本工作流只在 GitHub Actions 上执行。 +# 本仓托管在 Gitee,此文件不会自动运行——真正的提交门禁是本地 pre-commit +# (见 .pre-commit-config.yaml 的 sapcli-unit-tests)。若将来迁到 GitHub, +# 此文件即可直接生效。 +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # 工具源码与 pyproject 位于 assets/(本仓同时是技能包与工具源码仓) + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e "./assets[dev]" + + - name: Run tests + run: python -m unittest discover -s tests -p "test_*.py" -v + + - name: Coverage check + run: | + pip install coverage + coverage run -m unittest discover -s tests -p "test_*.py" + coverage report --include="assets/sapcli/*" --fail-under=70 diff --git a/.gitignore b/.gitignore index 85450c5..587b3bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,41 @@ -assets/**/__pycache__/ -assets/log/ +# ── Python ── +__pycache__/ +*.py[cod] +*$py.class + +# ── 打包/安装产物 ── +dist/ +build/ +*.egg-info/ +*.egg + +# ── 测试/覆盖率 ── +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage_html/ +coverage.xml +test_output/ + +# ── SAP 连接配置(含凭据,绝不入库)── +config.ini assets/config.ini -*.pyc + +# ── 运行期产物(log/ 下的 md 知识库要入库,只有运行日志忽略)── +assets/log/ +log/adt_tools.log + +# ── 编辑器/系统 ── +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# ── 评审产物 ── +REVIEW_REPORT.md + +# ── Claude Code 本地设置(含机器相关权限,不入库)── +.claude/settings.local.json diff --git a/.hermes/include-uri-fix-spec.md b/.hermes/include-uri-fix-spec.md new file mode 100644 index 0000000..e9a7497 --- /dev/null +++ b/.hermes/include-uri-fix-spec.md @@ -0,0 +1,89 @@ +# Spec: 修复 include 类型的 ADT URI 端点 + +## 问题描述 +`download --type include` 在 NW 7.40 上失败,返回"对象不存在"。 + +## 根因分析 +`types.py` 中 INCLUDE 的 URI 模板复用了 REPORT 的端点: +- 当前(错误):`/sap/bc/adt/programs/programs/{name}` +- 正确端点:`/sap/bc/adt/programs/includes/{name}` + +NW 7.40 ADT 对 include 和 report 使用不同的 REST 端点: +- report → `/sap/bc/adt/programs/programs/{name}` +- include → `/sap/bc/adt/programs/includes/{name}` + +## 已验证的正确端点 +| 用途 | URI | +|------|-----| +| metadata (object_exists) | `GET /sap/bc/adt/programs/includes/{name}` Accept: `application/vnd.sap.adt.programs.includes.v2+xml` | +| source (get_source) | `GET /sap/bc/adt/programs/includes/{name}/source/main` Accept: `text/plain` | + +## 修改范围 + +### 文件 1: `sapcli/types.py` (第 130-137 行) + +修改前: +```python +INCLUDE = _register(ObjectTypeConfig( + key="include", + label="Include程序(INCLUDE)", + obj_uri_template="/sap/bc/adt/programs/programs/{name}", + src_uri_template="/sap/bc/adt/programs/programs/{name}/source/main", + collection_uri="/sap/bc/adt/programs/programs", + create_content_type="application/vnd.sap.adt.programs.v2+xml", +)) +``` + +修改后: +```python +INCLUDE = _register(ObjectTypeConfig( + key="include", + label="Include程序(INCLUDE)", + obj_uri_template="/sap/bc/adt/programs/includes/{name}", + src_uri_template="/sap/bc/adt/programs/includes/{name}/source/main", + collection_uri="/sap/bc/adt/programs/includes", + create_content_type="application/vnd.sap.adt.programs.includes.v2+xml", +)) +``` + +### 文件 2: `sapcli/client/_base.py` (object_exists 方法) + +当前 `object_exists` 使用 `Accept: */*`,对于 include 端点可能需要特定的 Accept header。但根据测试,`*/*` 也能返回 200,所以这个方法可能不需要修改。如果 object_exists 对 include 返回 406,需要改为使用 `application/vnd.sap.adt.programs.includes.v2+xml`。 + +### 文件 3: `sapcli/commands/crud.py` + +`cmd_download` 和 `cmd_sync` 中有硬编码检查: +```python +if parsed.src_uri is None: + print(f"\n ✗ {type_label}没有源代码,不支持 download 操作") + print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") +``` +第二行错误提示只适用于 functiongroup,include 走不到这个分支(因为 src_uri 不是 None),所以不需要修改。 + +## 验证标准 + +修改后,以下命令必须能正常工作: + +```bash +# 1. 下载 include 源代码 +python main.py download --name LZIDT_MCP_TOOLU04 --type include --path /tmp/test + +# 2. 下载各种 include +python main.py download --name LZIDT_MCP_TOOLTOP --type include --path /tmp/test +python main.py download --name LZIDT_MCP_TOOLF01 --type include --path /tmp/test + +# 3. 确认原有 report 类型不受影响 +python main.py download --name ZIDT_MCP_LOG --type report --path /tmp/test +``` + +## 测试 + +运行现有测试确保无回归: +```bash +python -m pytest tests/ -v +``` + +## 约束 +- 只修改 `sapcli/types.py` 中的 INCLUDE 配置(4 个 URI 字段) +- 不修改 `_base.py`、`_source.py` 或其他文件的通用逻辑 +- 不引入新的依赖 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a91fdde --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +# Pre-commit hooks configuration +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: ['--maxkb=1024'] # docs/assets/architecture.drawio.png 约 512KB + - id: check-merge-conflict + - id: no-commit-to-branch + args: ['--branch', 'main'] + + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black'] + + # 本仓托管在 Gitee,.github/workflows 不会执行——测试门禁落在本地 pre-commit。 + # 安装:pip install pre-commit && pre-commit install + - repo: local + hooks: + - id: sapcli-unit-tests + name: sap-cli 全量测试(698 例) + entry: python -m unittest discover -s tests -p "test_*.py" + language: system + pass_filenames: false + files: '\.py$' + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: ['--max-line-length=120', '--extend-ignore=E501,W503'] + additional_dependencies: [flake8-bugbear] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eb4a84e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +sap-cli 是一个 Python CLI 工具,通过 SAP ADT REST API 在本地编辑器编写 ABAP 代码并同步到 SAP 系统。 + +**版本**: 2.3.0 | **命令数**: 26 | **对象类型**: 16 + +## 运行方式 + +```bash +cd D:/Codespace/sap-cli +python main.py [options] +# 或 +python -m sapcli [options] +``` + +## 规则体系(必须遵守) + +| 规则文件 | 核心内容 | +|----------|----------| +| [`sap-tool-constraints.md`](.claude/rules/sap-tool-constraints.md) | 🚫 禁止绕过 sap-cli 直接操作 SAP、禁止修改标准对象、禁止操作系统表 | +| [`error-handling.md`](.claude/rules/error-handling.md) | 命令失败时的诊断流程和处理原则 | + +> ⚠️ **这些规则由 Hooks 强制执行**,违反会被直接拦截。 + +## 命令速查 + +### CRUD(对象生命周期) + +```bash +python main.py create --name ZMY_CLASS --type class --corr_nr DEVK901XXX +python main.py delete --name ZMY_CLASS --type class +python main.py download --name ZMY_CLASS --type class --path ./out +python main.py info --name ZMY_CLASS --type class +python main.py sync --name ZMY_CLASS --type class --path ./src/classes/zmy_class.abap --corr_nr DEVK901XXX +python main.py activate --name ZMY_CLASS --type class +``` + +### 批量操作 + +```bash +python main.py init --path D:/Codespace/SAPilot/src +python main.py refresh --path D:/Codespace/SAPilot/src +python main.py sync --all --path D:/Codespace/SAPilot/src --corr_nr DEVK901362 +python main.py sync --all --path D:/Codespace/SAPilot/src --corr_nr DEVK901362 --dry-run +``` + +### 数据查询 + +```bash +python main.py show-table --name ZSAPILOT_OBJ # 表结构(字段列表) +python main.py read-table --name ZSAPILOT_OBJ # 表数据(freestyle SQL) +python main.py read-table --name ZSAPILOT_OBJ --max-rows 50 +python main.py list --type class --package ZSAPILOT # 列出对象 +python main.py whereused --name ZMY_CLASS --type class # Where-Used +python main.py search --pattern "sapilot" --type class # 源码搜索 +``` + +### 远程执行 + +```bash +python main.py run-program --name ZSAPILOT_SETUP # 执行 ABAP 程序 +python main.py diff --name ZMY_CLASS --type class --path ./src/classes/zmy_class.abap +``` + +### 传输请求 + +```bash +python main.py transport list +python main.py transport info --corr_nr DEVK901362 +python main.py transport release --corr_nr DEVK901362 +python main.py transport objects --corr_nr DEVK901362 +``` + +### 配置与认证 + +```bash +python main.py config show +python main.py config list-profiles +python main.py config set host s4h.example.com +python main.py auth login +python main.py auth status +``` + +### 其他 + +```bash +python main.py check --name ZMY_CLASS --type class # ATC 代码检查 +python main.py format --name ZMY_CLASS --type class # Pretty Printer +python main.py package info --name ZSAPILOT +python main.py cds download --name ZMY_CDS +python main.py analyze --path ./src # 依赖分析 +python main.py scaffold --type alv-report --name ZMY_RPT # 项目模板 +``` + +## 支持的对象类型(16 种) + +`report` `class` `interface` `function` `functiongroup` `domain` `dataelement` `table` `structure` `tabletype` `include` `cdsview` `messageclass` `view` `searchhelp` `lockobject` + +## 关键参数 + +| 参数 | 说明 | +|------|------| +| `--corr_nr DEVK901XXX` | 传输请求号(必带,否则触发交互提示) | +| `--path ` | 本地文件或项目目录路径 | +| `--dry-run` | 预览不执行 | +| `--definition ` | DDIC 对象创建定义(JSON 文件) | +| `--profile ` | 使用指定配置 profile | +| `--verify-ssl` | 启用 SSL 验证(默认关闭) | + +## 批量同步依赖排序 + +``` +domain(10) → dataelement(20) → table(30) → tabletype(40) → interface(50) → class(60) → function(70) → report(80) +``` + +## 配置文件 + +- `config.ini` — SAP 连接配置(host, client, user, password) +- 测试环境配置复用此文件 + +## 文档索引 + +| 文档 | 用途 | +|------|------| +| `README.md` | 价值说明、快速开始 | +| `FEATURES.md` | 命令矩阵、对象类型全景 | +| `docs/guide.md` | 详细命令用法、参数、输出示例 | +| `docs/adt/` | ADT REST API 学习笔记 | +| `docs/NW740-COMPATIBILITY.md` | NW 7.40 真实系统兼容性矩阵 | +| `docs/dev/` | 开发者文档 | diff --git a/README.md b/README.md index 7dfc468..891865d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ SAP-CLI-SKILL 是一个让 AI 助手直接操作 SAP ABAP 系统的技能。 - **[INSTALL.md](INSTALL.md)** - 完整安装指南 - **[SKILL.md](SKILL.md)** - AI Agent 使用指南 - **[references/](references/)** - 技术文档 +- **[docs/RELEASING.md](docs/RELEASING.md)** - 开发与发布 + +--- + +## 🗂 本仓是唯一来源 + +本仓同时是**技能分发包**与**工具源码仓**——`SKILL.md` 手工维护,工具源码在 +`assets/`,测试在 `tests/`(692 例)。原 `sap-cli` 源码仓已于 2026-09-11 归档。 + +```bash +pip install -e "./assets[dev]" +python -m unittest discover -s tests -p "test_*.py" +``` --- diff --git a/assets/pyproject.toml b/assets/pyproject.toml index 75890fd..558c91d 100644 --- a/assets/pyproject.toml +++ b/assets/pyproject.toml @@ -1,12 +1,12 @@ [build-system] requires = ["setuptools>=68.0", "wheel"] -build-backend = "setuptools.backends._legacy:_Backend" +build-backend = "setuptools.build_meta" [project] name = "sap-cli" dynamic = ["version"] description = "基于 SAP ADT REST API 的 ABAP 开发对象管理工具" -readme = "README.md" +readme = {text = "基于 SAP ADT REST API 的 ABAP 开发对象管理工具。本仓(sap-cli-skill)自 2026-09-11 起为唯一来源,原 sap-cli 源码仓已归档。", content-type = "text/markdown"} license = {text = "MIT"} requires-python = ">=3.10" authors = [ @@ -25,9 +25,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://gitee.com/markwury168/sap-cli" -Repository = "https://gitee.com/markwury168/sap-cli" -Issues = "https://gitee.com/markwury168/sap-cli/issues" +Homepage = "https://gitee.com/markwury168/sap-cli-skill" +Repository = "https://gitee.com/markwury168/sap-cli-skill" +Issues = "https://gitee.com/markwury168/sap-cli-skill/issues" [project.optional-dependencies] keyring = ["keyring>=23.0"] diff --git a/docs/NW740-COMPATIBILITY.md b/docs/NW740-COMPATIBILITY.md new file mode 100644 index 0000000..1f1967f --- /dev/null +++ b/docs/NW740-COMPATIBILITY.md @@ -0,0 +1,72 @@ +# NW 7.40 兼容性矩阵 + +> 最后更新:2026-07-09 +> 测试系统:support.learningleader.com.cn (NW 7.40) +> 测试方式:真实 SAP 系统端到端 create→info→delete→download→sync + +## 背景 + +sap-cli 封装 SAP ADT REST API,但不同 SAP NetWeaver 版本的 ADT 端点覆盖范围差异显著。本文件记录 v2.2.0 在真实 NW 7.40 系统上的验证结果,供用户和开发者参考。 + +## 对象类型 × 操作 可用性矩阵 + +### ✅ 完整可用(全生命周期闭环) + +| 类型 | create | info | delete | download | sync | 说明 | +|------|:------:|:----:|:------:|:--------:|:----:|------| +| report | ✅ | ✅ | ✅ | ✅ | ✅ | 报表程序 | +| class | ✅ | ✅ | ✅ | ✅ | ✅ | ABAP 类 | +| interface | ✅ | ✅ | ✅ | ✅ | ✅ | ABAP 接口 | +| function | ✅ | ✅ | ✅ | ✅ | ✅ | 函数模块(含函数组自动创建) | +| functiongroup | ✅ | ✅ | ✅ | — | — | 函数组(容器对象,无源码) | +| include | ✅ | ✅ | ✅ | ✅ | ✅ | Include 程序 | + +> **这 6 种类型构成 NW 7.40 上 sap-cli 的核心价值闭环**:download → 本地编辑 → sync。 + +### ⚠️ 部分可用(仅 CRUD,无源码编辑) + +| 类型 | create | info | delete | download | sync | 说明 | +|------|:------:|:----:|:------:|:--------:|:----:|------| +| domain | ✅ | ✅ | ✅ | ❌ | ❌ | 源码端点 /source/main 在 NW 7.40 不存在 | +| dataelement | ✅ | ✅ | ✅ | ❌ | ❌ | 同上 | + +> DDIC 对象在 NW 7.40 上以 XML 形式存储于对象 URI 本身,没有独立的 source/main 端点。这是 NW 7.50+ 才引入的特性。即便 Eclipse ADT 在 7.40 上也无法编辑 DDIC 源码——这不是 sap-cli 的缺陷。 + +### ❌ 不可用(NW 7.40 端点缺失) + +| 类型 | 失败原因 | HTTP 状态码 | 代码状态 | +|------|----------|:-----------:|----------| +| table | 集合端点 POST Content-Type 不匹配 | 415 | 代码已写,待更正 | +| structure | 集合端点 POST Content-Type 不匹配 | 415 | 代码已写,待更正 | +| tabletype | DDIC tabletypes 集合端点不存在 | 404 | 代码已写,端点缺失 | +| view | 媒体类型不支持 | 415 | 代码已写,端点限制 | +| messageclass | handler 未注册 | 404 | 代码已写,端点缺失 | +| searchhelp | 端点不存在 | 404 | 代码已写,端点缺失 | +| lockobject | 端点不存在 | 404 | 代码已写,端点缺失 | +| cdsview | DDL source 端点不存在 | 404 | 代码已写,端点缺失 | + +> **判定原则**:这些类型的 `_build_create_body` XML 结构代码已就位,但 NW 7.40 的 ADT 端点层就不通。验证方式——如果是 XML 结构错误,SAP 会返回 400 校验消息;而这 8 种类型返回的都是 404/415(端点不存在/媒体类型不支持),请求未到达 XML 解析。需在 S/4HANA 或更高 NW 版本上验证。 + +## 其他命令的版本兼容性 + +| 命令 | NW 7.40 | 说明 | +|------|:-------:|------| +| list | ✅ | 对象列表 | +| whereused | ✅ | 引用查询 | +| search | ✅ | 源码搜索 | +| check (ATC) | ✅ | 代码检查 | +| format | ✅ | Pretty Printer | +| diff | ✅ | 本地 vs SAP 对比 | +| transport | ✅ | 传输请求管理 | +| package | ✅ | 包管理 | +| show-table | ✅ | 表结构查询(SE11) | +| read-table | ✅ | 表数据查询 | +| run-program | ✅ | 远程执行程序 | +| activate | ✅ | 对象激活 | + +## 已知限制 + +1. **DDIC 对象 sync 不可用** — sync 流程依赖 `/source/main` 端点,NW 7.40 不支持。需 NW 7.50+。 +2. **DDIC 对象 lock Accept 头** — `delete_object` 已修复(传 `application/vnd.sap.as+xml`),sync 流程未扩展(因 source/main 限制无意义)。 +3. **table/structure create** — 返回 415(Content-Type 不匹配),需调研 NW 7.40 集合端点的正确 POST 方式。 +4. **NW 7.40 激活空响应** — activate 可能返回空响应,已有 double-activate 变通机制,极端情况提示去 SE09 手动激活。 diff --git a/docs/OPERATION_MANUAL.md b/docs/OPERATION_MANUAL.md new file mode 100644 index 0000000..f4ec746 --- /dev/null +++ b/docs/OPERATION_MANUAL.md @@ -0,0 +1,689 @@ +# sap-cli 操作手册 + +> 版本 2.3.0 · 最后更新 2026-08-06 + +--- + +## 目录 + +1. [项目概述](#1-项目概述) +2. [项目结构](#2-项目结构) +3. [安装与配置](#3-安装与配置) +4. [日常使用:命令速查](#4-日常使用命令速查) +5. [典型工作流](#5-典型工作流) +6. [测试管理](#6-测试管理) +7. [版本与发布](#7-版本与发布) +8. [开发指南](#8-开发指南) +9. [故障排查](#9-故障排查) + +--- + +## 1. 项目概述 + +**sap-cli** 是一个基于 SAP ADT REST API 的 ABAP 开发对象管理工具。 + +**能做什么:** +- 从 SAP 系统下载/上传 ABAP 源代码 +- 在本地编辑器编写 ABAP,一键同步到 SAP 并激活 +- 批量管理项目(init → sync --all → refresh) +- 16 种 SAP 对象类型(report/class/table/CDS View 等) +- 传输请求管理、代码搜索、依赖分析 + +**基本信息:** +- 仓库地址:https://gitee.com/markwury168/sap-cli +- 本地路径:`D:\Codespace\sap-cli\` +- 运行方式:`python main.py <命令>` 或 `sap-cli <命令>`(安装后) +- Python 版本:≥ 3.10 + +--- + +## 2. 项目结构 + +``` +D:\Codespace\sap-cli\ +│ +├── main.py # 入口文件(python main.py <命令>) +├── config.ini # SAP 连接配置(不入库,含密码) +├── config.ini.example # 配置模板 +├── pyproject.toml # 项目构建配置 + 版本号 +├── requirements.txt # 依赖(备用,首选 pyproject.toml) +├── README.md # 项目介绍 + 快速上手 +├── FEATURES.md # 功能全景文档 +├── CHANGELOG.md # 版本变更记录 +├── CONTRIBUTING.md # 协作规范 +├── LICENSE # MIT 许可 +│ +├── sapcli/ # 源码包(核心代码) +│ ├── __init__.py # 版本号 __version__ = "2.3.0" +│ ├── cli/ # CLI 层(参数解析 + 入口路由) +│ │ ├── parser.py # 228 行 — argparse 命令定义 +│ │ ├── app.py # 159 行 — main() 入口 + 命令路由 +│ │ └── output.py # 50 行 — 彩色输出工具 +│ ├── client/ # API 层(SAP ADT 通信)— Mixin 架构 +│ │ ├── _base.py # 88 行 — 连接、认证、通用请求 +│ │ ├── _source.py # 354 行 — 源码读写、锁定、激活 +│ │ ├── _transport.py # 203 行 — 传输请求管理 +│ │ ├── _search.py # 197 行 — 搜索、Where-Used +│ │ ├── _ddic.py # 639 行 — 对象创建/删除/DDIC +│ │ └── __init__.py # 29 行 — ADTClient = 5个Mixin组合 +│ ├── commands/ # 命令层(业务逻辑) +│ │ ├── crud.py # 890 行 — download/sync/delete/info/create +│ │ ├── batch.py # 328 行 — init/sync-all/refresh +│ │ ├── search.py # 147 行 — list/whereused/search +│ │ ├── transport.py # 136 行 — 传输请求操作 +│ │ ├── cds.py # 186 行 — CDS View 操作 +│ │ ├── package_cmd.py # 107 行 — ABAP 包管理 +│ │ ├── quality.py # 131 行 — ATC 检查/格式化 +│ │ ├── diff_cmd.py # 89 行 — 代码差异对比 +│ │ ├── analyze.py # 126 行 — 依赖分析 +│ │ ├── scaffold.py # 251 行 — 项目模板 +│ │ ├── config_cmd.py # 106 行 — 配置管理 +│ │ └── __init__.py # 69 行 — 统一导出 +│ ├── config.py # 95 行 — 配置加载 +│ ├── auth.py # 124 行 — 密钥管理(keyring) +│ ├── password.py # 76 行 — 密码解析(从 auth 拆出) +│ ├── types.py # 243 行 — 16 种对象类型定义 +│ ├── manifest.py # 174 行 — 项目清单管理 +│ ├── scanner.py # 121 行 — 项目目录扫描 +│ ├── sorter.py # 152 行 — 拓扑排序 +│ ├── ddic.py # 277 行 — DDIC XML 构建 +│ ├── exceptions.py # 77 行 — 异常体系 +│ └── utils/ # 工具函数 +│ ├── xml_utils.py # 13 行 — XML 转义 +│ └── __init__.py +│ +├── tests/ # 测试(412 用例,84% 覆盖率) +│ ├── test_sapcli.py # 68 个 — 基础层测试 +│ └── unit/ # 单元测试 +│ ├── test_client.py # 82 个 — API 层测试 +│ ├── test_commands.py # 45 个 — 命令层测试 +│ ├── test_commands_extra.py # 64 个 — 命令边界场景 +│ ├── test_cli.py # 41 个 — 参数解析测试 +│ ├── test_batch_analyze.py # 40 个 — 批量操作+分析 +│ ├── test_modules.py # 58 个 — 散落模块测试 +│ └── test_app_config.py # 14 个 — 入口+配置测试 +│ +├── docs/ # 文档 +│ ├── guide.md # 详细使用指南(24KB) +│ ├── adt/ # 7 篇 ADT 原理文章 +│ └── dev/ # 开发文档 +│ ├── architecture.md # 架构说明 +│ ├── CLAUDE.md # AI 协作规范 +│ ├── AGENTS.md # AI Agent 规范 +│ ├── test-plan.md # 测试方案 +│ └── test-coverage-report.md # 覆盖率报告 +│ +├── openspec/ # OpenSpec SDD 规范 +│ ├── specs/ # 7 个领域的需求规格 +│ └── changes/ # 变更记录(已归档) +│ +├── .github/workflows/ci.yml # CI 自动测试 +├── .editorconfig # 编辑器统一配置 +├── .pre-commit-config.yaml # Git 提交前自动检查 +└── .gitignore # Git 忽略规则 +``` + +### 架构分层 + +``` +用户输入 + │ + ▼ +main.py → cli/app.py → cli/parser.py ← CLI 层:解析参数 + │ + ▼ +commands/*.py ← 命令层:业务逻辑 + │ + ▼ +client/*.py (Mixin) ← API 层:SAP ADT 通信 + │ + ▼ +config.py / types.py / manifest.py / ... ← 基础层:工具与配置 +``` + +--- + +## 3. 安装与配置 + +### 3.1 首次安装 + +```bash +cd D:\Codespace\sap-cli + +# 安装项目(开发模式,代码修改立即生效) +pip install -e . + +# 安装可选依赖(密码安全存储) +pip install -e ".[keyring]" + +# 安装开发依赖(测试工具) +pip install -e ".[dev]" +``` + +安装后可以直接使用 `sap-cli` 命令(无需 `python main.py`)。 + +### 3.2 SAP 连接配置 + +**方式一:配置文件(推荐)** + +复制模板并填写你的 SAP 系统信息: + +```bash +cp config.ini.example config.ini +``` + +编辑 `config.ini`: + +```ini +[SAP] +host = http://your-sap-server:8000 +client = 100 +user = your_username +password = your_password + +; 多系统配置(用 --profile 切换): +[DEV] +host = http://dev-sap:8000 +client = 100 +user = dev_user +password = dev_password + +[QAS] +host = http://qas-sap:8000 +client = 200 +user = qas_user +password = qas_password +``` + +> ⚠️ `config.ini` 包含密码,已在 `.gitignore` 中排除,**绝不要提交到 Git**。 + +**方式二:环境变量** + +```bash +set SAP_HOST=http://your-sap:8000 +set SAP_CLIENT=100 +set SAP_USER=your_username +set SAP_PASSWORD=your_password +``` + +**方式三:keyring 安全存储** + +```bash +python main.py auth login +# 输入 host / client / user / password 后,密码存入系统密钥环 + +python main.py auth status # 查看状态 +python main.py auth logout # 删除密码 +``` + +**密码优先级:** 环境变量 > keyring > config.ini + +### 3.3 多系统切换 + +```bash +# 默认使用 [SAP] 配置 +python main.py download --name ZTEST --type report --path ./out + +# 切换到 [DEV] 系统 +python main.py --profile DEV download --name ZTEST --type report --path ./out + +# 切换到 [QAS] 系统 +python main.py --profile QAS info --name ZCL_MY --type class +``` + +### 3.4 SSL 证书 + +默认关闭 SSL 验证(SAP 开发环境通常使用自签名证书)。如果你的 SAP 系统有正式证书: + +```bash +python main.py --verify-ssl download --name ZTEST --type report --path ./out +``` + +--- + +## 4. 日常使用:命令速查 + +### 4.1 全局参数 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `--config` | 指定配置文件路径 | `--config ./my.ini` | +| `--profile` / `-p` | 切换系统配置 | `--profile DEV` | +| `--verify-ssl` | 启用 SSL 证书验证 | `--verify-ssl` | + +### 4.2 支持的 16 种对象类型 + +``` +report class interface function functiongroup +domain dataelement table structure tabletype +include cdsview messageclass view searchhelp lockobject +``` + +### 4.3 命令一览(20 个) + +#### 核心操作 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **download** | 下载 SAP 对象源码到本地 | `python main.py download --name ZTEST --type report --path ./out` | +| **sync** | 上传本地代码到 SAP 并激活 | `python main.py sync --name ZTEST --type report --path ./ztest.abap` | +| **create** | 在 SAP 创建新对象 | `python main.py create --name ZTEST --type report` | +| **delete** | 从 SAP 删除对象 | `python main.py delete --name ZTEST --type report` | +| **info** | 查询对象元数据 | `python main.py info --name ZCL_MY --type class` | + +#### 批量项目管理 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **init** | 初始化项目清单 | `python main.py init --path ./my_project` | +| **refresh** | 刷新清单中的 SAP 状态 | `python main.py refresh --path ./my_project` | + +#### 批量同步模式 + +```bash +# 批量同步所有对象 +python main.py sync --all --path ./my_project + +# 预览模式(不实际操作) +python main.py sync --all --path ./my_project --dry-run + +# 遇到失败立即停止 +python main.py sync --all --path ./my_project --fail-fast + +# 指定传输请求号 +python main.py sync --name ZTEST --type report --path ./ztest.abap --corr_nr DEVK901362 +``` + +#### 搜索与浏览 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **list** | 列出 SAP 对象 | `python main.py list --type report --prefix Z*` | +| **whereused** | Where-Used 引用查询 | `python main.py whereused --name ZTEST --type report` | +| **search** | 源代码搜索 | `python main.py search --query "CALL FUNCTION"` | +| **analyze** | 依赖自动分析 | `python main.py analyze --path ./my_project` | + +#### 传输管理 + +```bash +python main.py transport list # 列出可修改的传输请求 +python main.py transport info --corr_nr DEVK901362 # 查看传输详情 +python main.py transport release --corr_nr DEVK901362 # 释放传输 +python main.py transport objects --corr_nr DEVK901362 # 列出传输中的对象 +``` + +#### 代码质量 + +| 命令 | 用途 | 示例 | +|------|------|------| +| **check** | ATC 代码检查 | `python main.py check --name ZTEST --type report` | +| **format** | ABAP Pretty Printer | `python main.py format --name ZTEST --type report` | +| **diff** | 本地 vs SAP 差异 | `python main.py diff --name ZTEST --type report --path ./ztest.abap` | + +#### CDS View + +```bash +python main.py cds download --name ZMY_CDS --path ./out # 下载 DDL +python main.py cds sync --name ZMY_CDS --path ./ddl.abap # 同步 DDL +python main.py cds create --name ZMY_CDS # 创建 CDS +``` + +#### 包管理 + +```bash +python main.py package create --name ZMY_PKG --description "我的包" # 创建 +python main.py package info --name ZMY_PKG # 查看 +python main.py package list --name ZMY_PKG # 列出对象 +``` + +#### 项目模板 + +```bash +# 列出可用模板 +python main.py scaffold --name ZTEST + +# 使用指定模板 +python main.py scaffold --name ZTEST --template alv-report # ALV 报表 +python main.py scaffold --name ZTEST --template bapi-wrapper # BAPI 封装 +python main.py scaffold --name ZTEST --template interface-class # 接口类 +python main.py scaffold --name ZTEST --template data-model # 数据模型 +``` + +#### 配置与认证 + +```bash +python main.py config show # 显示当前配置 +python main.py config list-profiles # 列出所有 profile +python main.py config set host http://new-host:8000 # 修改配置 + +python main.py auth login # 保存密码到 keyring +python main.py auth logout # 删除密码 +python main.py auth status # 查看状态 +``` + +--- + +## 5. 典型工作流 + +### 工作流 A:下载 → 编辑 → 同步 + +```bash +# 1. 下载 SAP 对象源码 +python main.py download --name ZMY_REPORT --type report --path ./src + +# 2. 用你喜欢的编辑器修改 ./src/zmy_report.abap + +# 3. 同步回 SAP 并激活 +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 工作流 B:新建项目 + 批量开发 + +```bash +# 1. 创建项目目录结构 +mkdir my_project +mkdir my_project\reports my_project\classes my_project\functions + +# 2. 在各目录下编写 .abap 文件 + +# 3. 初始化项目清单(扫描目录 → 查询 SAP → 写 manifest.json) +python main.py init --path ./my_project + +# 4. 批量同步到 SAP +python main.py sync --all --path ./my_project + +# 5. 后续修改后再次同步 +python main.py sync --all --path ./my_project + +# 6. 刷新 SAP 状态 +python main.py refresh --path ./my_project +``` + +### 工作流 C:使用模板快速创建 + +```bash +# 1. 生成 ALV 报表模板 +python main.py scaffold --name ZALV_DEMO --template alv-report --path ./my_project + +# 2. 编辑生成的文件 +# my_project/reports/zalv_demo.abap + +# 3. 初始化 + 同步 +python main.py init --path ./my_project +python main.py sync --all --path ./my_project +``` + +### 工作流 D:DDIC 对象管理 + +```bash +# 创建 DDIC 对象(需要 JSON 定义文件) +python main.py create --name ZMY_DOMAIN --type domain --definition ./domain_def.json +python main.py create --name ZMY_TABLE --type table --definition ./table_def.json + +# 创建时指定包和传输请求 +python main.py create --name ZMY_CLASS --type class --package ZMY_PKG --corr_nr DEVK901362 +``` + +### 项目目录命名规范 + +批量模式时,scanner 按目录名识别对象类型: + +| 目录名 | 对象类型 | +|--------|---------| +| `reports/` | report | +| `classes/` | class | +| `interfaces/` | interface | +| `functions/` | function(子目录 = 函数组名) | +| `domains/` | domain | +| `dataelements/` | dataelement | +| `tables/` | table | +| `structures/` | structure | +| `tabletypes/` | tabletype | +| `includes/` | include | +| `cdsviews/` | cdsview | +| `messageclasses/` | messageclass | +| `views/` | view | +| `searchhelps/` | searchhelp | +| `lockobjects/` | lockobject | + +函数组特殊结构: +``` +functions/ +└── zmy_group/ ← 函数组名(子目录名) + ├── zfunc1.abap ← 函数模块 + └── zfunc2.abap +``` + +--- + +## 6. 测试管理 + +### 6.1 运行测试 + +```bash +cd D:\Codespace\sap-cli + +# 运行全部测试(412 用例) +python -m unittest discover -s tests -p "test_*.py" + +# 运行单个测试文件 +python tests/unit/test_client.py +python tests/test_sapcli.py + +# 运行特定测试类 +python -m unittest tests.unit.test_cli.TestParserDownload + +# 运行特定测试方法 +python -m unittest tests.unit.test_client.TestADTClient.test_login_success +``` + +### 6.2 查看覆盖率 + +```bash +# 安装 coverage +pip install coverage + +# 运行测试并收集覆盖率 +coverage run -m unittest discover -s tests -p "test_*.py" + +# 查看终端报告 +coverage report --include="sapcli/*" + +# 生成 HTML 报告(浏览器打开) +coverage html --include="sapcli/*" +# 报告在 tests/coverage_html/index.html +``` + +### 6.3 测试文件说明 + +| 文件 | 用例数 | 覆盖内容 | +|------|--------|----------| +| `test_sapcli.py` | 68 | 基础层(types/config/exceptions/manifest) | +| `test_client.py` | 82 | API 层(ADTClient 全部方法) | +| `test_commands.py` | 45 | 命令层(各命令 happy path) | +| `test_commands_extra.py` | 64 | 命令层(边界场景/错误处理) | +| `test_cli.py` | 41 | 参数解析(全部 20 个命令) | +| `test_batch_analyze.py` | 40 | 批量操作 + 依赖分析 | +| `test_modules.py` | 58 | 散落模块(scanner/sorter/auth/password) | +| `test_app_config.py` | 14 | CLI 入口 + 配置命令 | + +### 6.4 当前覆盖率(84%) + +``` +100% — parser, manifest, exceptions, utils, commands/__init__ +96%+ — batch, sorter, scanner, scaffold, types, config +90%+ — search, transport, client (各 Mixin) +80%+ — app, auth, config_cmd, ddic, quality +60%+ — crud, output +``` + +--- + +## 7. 版本与发布 + +### 7.1 版本号在哪 + +版本号 **只在一个地方维护**: + +``` +sapcli/__init__.py → __version__ = "2.3.0" +``` + +`pyproject.toml` 通过动态读取自动同步: +```toml +[tool.setuptools.dynamic] +version = {attr = "sapcli.__version__"} +``` + +**升级版本只需改 `sapcli/__init__.py` 中的 `__version__`。** + +### 7.2 发布流程 + +```bash +# 1. 修改版本号 +# 编辑 sapcli/__init__.py → __version__ = "2.2.0" + +# 2. 更新 CHANGELOG.md + +# 3. 提交 +git add -A && git commit -m "release: v2.2.0" + +# 4. 打标签 +git tag v2.2.0 + +# 5. 推送 +git push --tags +``` + +### 7.3 Git 操作常用命令 + +```bash +# 查看状态 +git status + +# 查看历史 +git log --oneline -10 + +# 提交代码 +git add -A +git commit -m "feat: 新增 xxx 功能" +git push + +# 回退未提交的修改 +git checkout -- . + +# 查看 config.ini 是否被误加入暂存 +git ls-files | grep config.ini +``` + +--- + +## 8. 开发指南 + +### 8.1 添加新命令 + +**步骤:** + +1. 在 `sapcli/commands/` 下新建或修改命令文件: + ```python + # sapcli/commands/my_cmd.py + import argparse + from sapcli.client import ADTClient + + def cmd_my_command(args: argparse.Namespace, client: ADTClient) -> None: + """我的新命令。""" + print(f"执行 my-command: {args.name}") + ``` + +2. 在 `sapcli/commands/__init__.py` 中导出: + ```python + from sapcli.commands.my_cmd import cmd_my_command + ``` + +3. 在 `sapcli/cli/parser.py` 的 `build_parser()` 中添加子命令定义 + +4. 在 `sapcli/cli/app.py` 的 `command_map` 中注册路由 + +5. 写测试 → 运行测试 + +### 8.2 添加新对象类型 + +在 `sapcli/types.py` 中注册: +```python +_TYPE_REGISTRY["mytype"] = ObjectTypeConfig( + obj_type="mytype", + program_type="MYT", + adt_type="MYT", + adt_uri="mytypes", + # ... +) +``` + +然后在 `scanner.py` 的 `DIRECTORY_TYPE_MAP` 和 `sorter.py` 的 `TYPE_PRIORITY` 中补充。 + +### 8.3 代码风格 + +- Python PEP 8 +- `.editorconfig` 已配置:4 空格缩进,UTF-8,LF 换行 +- pre-commit hooks 自动检查(安装后运行 `pre-commit install`) + +### 8.4 新增 API 方法 + +API 方法在 `sapcli/client/` 目录的 Mixin 模块中: + +| 功能域 | 文件 | 示例方法 | +|--------|------|---------| +| 连接/认证 | `_base.py` | login, object_exists | +| 源码读写 | `_source.py` | get_source, set_source, lock, unlock | +| 传输管理 | `_transport.py` | list_transport_requests, transport_release | +| 搜索查询 | `_search.py` | list_objects, where_used, search_code | +| 对象操作 | `_ddic.py` | create_object, delete_object, create_ddic_object | + +添加新方法时,放在对应 Mixin 文件中即可。`ADTClient` 类自动继承所有 Mixin。 + +--- + +## 9. 故障排查 + +### 常见问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| `✗ 配置文件不存在` | 没有 config.ini | 复制 `config.ini.example` 为 `config.ini` 并填写 | +| `✗ 登录失败` | SAP 地址/密码错误 | 检查 config.ini 中的 host/user/password | +| `✗ 对象不存在` | 名称或类型不对 | 检查大小写,SAP 对象名通常大写 | +| `SSL 证书错误` | SAP 自签名证书 | 默认已关闭验证,检查是否误加 `--verify-ssl` | +| `keyring 不可用` | 未安装 keyring | `pip install keyring` | +| `config.ini 入了 Git` | 误提交 | `git rm --cached config.ini && git commit` | + +### 日志文件 + +运行日志自动写入 `log/adt_tools.log`(`.gitignore` 已排除)。 + +### 调试技巧 + +```bash +# 查看完整错误堆栈 +python main.py download --name ZTEST --type report --path ./out 2>&1 | more + +# 查看日志 +cat log/adt_tools.log | tail -50 + +# 验证配置是否正确 +python main.py config show + +# 验证连接是否可用 +python main.py list --type report --prefix Z* +``` + +--- + +## 附录:版本历史 + +| 版本 | 日期 | 关键变更 | +|------|------|---------| +| v2.1.0 | 2026-06-09 | 全面重构:20 命令、16 类型、Mixin 架构、412 测试 | +| v2.0.0 | 2026-06-08 | OpenSpec SDD 重构、项目结构整理 | +| v1.0.0 | 2026-05 | 初始版本 | + +> 详细变更记录见 `CHANGELOG.md` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..c5a8ab7 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,97 @@ +# 开发与发布 + +## 本仓是唯一来源(2026-09-11 起) + +`sap-cli-skill` 同时是**技能分发包**与**工具源码仓**,也是 SAP 相关实机操作的运行版。 +原 `sap-cli` 源码仓已**归档**(只读留档),不再接受改动。 + +``` +sap-cli-skill/ +├── SKILL.md ← 技能文档(手工维护,AI Agent 使用指南) +├── INSTALL.md ← 完整安装指南(手工维护) +├── README.md ← 项目说明 +├── VERSION ← 版本号(须与 assets/sapcli/__version__ 一致) +├── assets/ ← 工具源码与打包元数据(Python 包根) +│ ├── sapcli/ ← 工具源码 +│ ├── main.py +│ ├── pyproject.toml ← 版本动态取自 sapcli.__version__ +│ ├── requirements.txt +│ └── config.ini.example +├── references/ ← 规则正本(sap-tool-constraints / abap-coding-rules / error-handling) +├── tests/ ← 单元测试与 E2E 夹具 +├── log/ ← 错误知识库(LLM-WIKI 格式,md 入库) +├── docs/ ← 开发文档、ADT 原理文档 +├── openspec/ ← SDD 规格 +├── scripts/setup.py ← 安装脚本 +├── .claude/rules/ ← 规则副本(供 Claude Code,须与 references/ 一致) +└── .github/workflows/ ← CI +``` + +> 目录语义说明:`assets/` 是**技能包**的载荷目录(Hermes / Claude Code 技能约定), +> 因此工具源码放在其中而非仓库根。安装与调用都指向 `assets/`。 + +## 日常开发 + +```bash +cd D:/Gitee/sap-cli-skill + +# 装依赖(editable) +pip install -e "./assets[dev]" + +# 跑全量测试(692 例) +python -m unittest discover -s tests -p "test_*.py" + +# 只跑单元测试(更快) +python -m unittest discover -s tests/unit -t . +``` + +改代码 → 改文档(需要时)→ 跑测试 → 提交。**未绿不提交。** + +## 版本与发布 + +版本号权威源是 `assets/sapcli/__init__.py` 的 `__version__`: + +1. 改 `assets/sapcli/__init__.py` 的 `__version__` +2. 同步 `VERSION` 文件(`test_repo_guards.py` 会断言二者一致,忘了会红) +3. 提交并打 tag: + ```bash + git add -A && git commit -m "chore: bump to vX.Y.Z" + git tag -a vX.Y.Z -m "sap-cli vX.Y.Z" + git push origin HEAD && git push origin vX.Y.Z + ``` + +发布即推送本仓。Hermes 侧无需拷贝——`profiles/*/skills/productivity/sap-cli` +是指向本仓的 junction,专家下一轮即读到新内容。 + +## 规则文件:两处副本必须一致 + +`references/*.md` 是**正本**(随技能分发给专家、被 `SKILL.md` 引用); +`.claude/rules/*.md` 是供 Claude Code 读取的**副本**。 + +两者内容必须一致,由 `tests/unit/test_repo_guards.py` 的 +`TestRuleCopiesInSync` 断言。改规则时**两处都要改**,只改一处测试会红。 + +## 两道守卫(提交前必过) + +`tests/unit/test_repo_guards.py` 守住几类「静默失效」: + +| 守卫 | 防的问题 | +|------|----------| +| SKILL.md 记录 parser **全部** CLI 命令 | 文档与代码脱节(曾只写 9 个而实际 31 个) | +| 铁律 1–5 是**真实小节标题** | 子串检查被正文交叉引用蒙过 | +| 示例不得出现 `--path ./src` | 违反铁律 5 的目录结构 | +| `references/` 规则齐备且含关键规则 | 规则静默丢失 | +| `VERSION` == `sapcli.__version__` == README 版本 | 版本漂移(曾 2.3.0 vs 2.5.1) | +| `.claude/rules/` == `references/` | 规则副本漂移 | + +## 已废弃的做法(勿再使用) + +- **`pack_skill.py` / `skill-src/SKILL.md.tmpl` 打包流程**——已随 sap-cli 归档。 + 现在 `SKILL.md` 是手工维护的正本,**没有构建步骤**,也就没有「构建覆盖手改」的风险。 + 不要再引入「模板渲染 → 覆盖仓库文档」这类机制。 +- **`D:/Codespace/SAP-CLI-SKILL`**——早期开发副本,与分发仓同远端,2026-09-11 已删除。 +- **向 `D:/Gitee/sap-cli` 提交改动**——已归档。 + +## Gitee 远程 + +https://gitee.com/markwury168/sap-cli-skill.git diff --git a/docs/adt/01.ADT架构与通信原理.md b/docs/adt/01.ADT架构与通信原理.md new file mode 100644 index 0000000..2b4d35e --- /dev/null +++ b/docs/adt/01.ADT架构与通信原理.md @@ -0,0 +1,215 @@ +--- +title: ADT 架构与通信原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - architecture + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 架构与通信原理 + +> [!abstract] 核心洞察 +> ADT 本质上是一个 **REST API 客户端**。SAP 通过在 ABAP 服务器上构建一套完整的 REST 层(`/sap/bc/adt/*`),然后开发 Eclipse 插件来消费这些 API,实现了前后端的完全解耦。 + +## 1. 三层架构模型 + +ADT 采用经典的 **客户端/服务器** 分层架构: + +``` +┌───────────────────────────────────────────────────────────┐ +│ Layer 1: IDE 客户端层 (Eclipse / VS Code / 自定义客户端) │ +│ │ +│ • Eclipse ADT 插件(Java 实现) │ +│ • VS Code 扩展(2026年起支持,复用 Eclipse 代码库) │ +│ • 任何 HTTP 客户端(Python requests / JS fetch / AI Agent) │ +│ • 客户端 UI 无关代码:2.9 百万行! │ +└────────────────────────┬──────────────────────────────────┘ + │ HTTP(S) + Basic Auth + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Layer 2: REST 通信层 (/sap/bc/adt/*) │ +│ │ +│ • 基于 HTTP 协议的 RESTful API │ +│ • 请求格式: XML / ATOM Feed / JSON │ +│ • CSRF Token 机制(写操作必须先获取 Token) │ +│ • 状态会话管理(Stateful HTTP Session) │ +│ • 服务发现端点: /sap/bc/adt/discovery │ +└────────────────────────┬──────────────────────────────────┘ + │ ICF Handler 路由 + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Layer 3: SAP ABAP 后端 │ +│ │ +│ • ICF (Internet Communication Framework) 处理请求路由 │ +│ • ADT REST Framework (增强点 SADT_REST_RFC_APPLICATION) │ +│ • ABAP Runtime(语法检查、编译、激活) │ +│ • Repository(仓库对象存储) │ +│ • CTS(传输管理系统) │ +│ • ATC(代码质量检查引擎) │ +└───────────────────────────────────────────────────────────┘ +``` + +## 2. REST 通信层详解 + +### 2.1 端点结构 + +所有 ADT 操作通过 `/sap/bc/adt/` 前缀的端点进行通信: + +| 端点路径 | 功能 | 最低版本要求 | +|----------|------|-------------| +| `/sap/bc/adt/discovery` | 服务发现(Atom Feed) | NW 7.31 SP04 | +| `/sap/bc/adt/repository` | 仓库对象搜索与浏览 | NW 7.31 SP04 | +| `/sap/bc/adt/oo` | 类与接口管理 | NW 7.31 SP04 | +| `/sap/bc/adt/programs` | 程序与 Include | NW 7.31 SP04 | +| `/sap/bc/adt/functions` | 函数组与函数模块 | NW 7.31 SP04 | +| `/sap/bc/adt/ddic` | ABAP Dictionary 访问 | NW 7.40 SP02 | +| `/sap/bc/adt/datapreview` | 表数据预览(SE16N 替代) | NW 7.40 SP05 | +| `/sap/bc/adt/cts` | 传输请求管理 | NW 7.40 SP02 | +| `/sap/bc/adt/activation` | 对象激活 | NW 7.31 SP04 | +| `/sap/bc/adt/atc` | ABAP Test Cockpit | NW 7.50+ | + +### 2.2 通信协议特征 + +> [!important] 关键协议特征 + +**认证方式:** +- **Basic Authentication**(最常用)— 通过 HTTPS 发送用户名/密码 +- **X.509 证书认证** — 生产环境推荐 +- **SAP OAuth 2.0** — 高级场景 + +**CSRF Token 机制:** +``` +1. 客户端发送 GET 请求,Header: x-csrf-token = "Fetch" +2. 服务器返回响应,Header 中包含 CSRF Token +3. 后续所有写操作(POST/PUT/DELETE)必须携带此 Token +4. Token 过期时(HTTP 403),重新获取并重试 +``` + +**会话管理:** +- ADT 使用 **有状态的 HTTP 会话**(Stateful Session) +- 通过 Cookie 维持会话,用于对象锁定等操作 +- `sap-client` Header 指定 SAP 客户端编号 + +**数据格式:** +- 请求/响应主要使用 **XML** 格式 +- 部分端点支持 `text/plain`(源代码读写) +- 服务发现使用 **ATOM Feed** 格式 + +## 3. 服务发现机制 + +> [!tip] 核心概念 +> ADT 采用 **服务发现** 模式,而非硬编码 URL。客户端可以通过 `/sap/bc/adt/discovery` 端点动态获取所有可用的服务及其 URI。 + +### 发现流程 + +``` +1. 客户端请求 GET /sap/bc/adt/discovery + → 返回 Atom Feed,列出所有已注册的服务集合 + +2. 每个服务集合包含: + - href: 服务的 URI 路径 + - title: 服务的描述名称 + - category: 服务的分类(scheme + term) + +3. 客户端根据 category scheme/term 查找需要的具体服务 URI + → 避免硬编码,支持不同系统版本的兼容性 +``` + +### 发现端点示例响应结构 + +```xml + + Classes + + +``` + +## 4. 后端扩展机制 + +### ADT REST Framework 的扩展点 + +SAP 提供了 **增强点 (Enhancement Spot)** 来扩展 ADT: + +| BAdI | 用途 | +|------|------| +| `BADI_ADT_DISCOVERY_PROVIDER` | 实现自定义发现提供者,注册新的 URI | +| `SADT_REST_RFC_APPLICATION` | 实现自定义 REST 资源处理 | + +### 扩展架构 + +``` +客户端请求 + │ + ▼ +RFC Handler(ADT REST Framework 核心) + │ + ├── 根据 URI 路径匹配对应的 BAdI 实现 + │ + ├── 调用 Discovery Controller(发现控制器) + │ └── 提供 URI schema(如 /ztransportutils) + │ + └── 调用 Resource Controller(资源控制器) + └── 处理具体的 GET/POST/PUT/DELETE 操作 +``` + +### 创建自定义 ADT 资源的步骤 + +1. **创建 Discovery Controller** — 继承 `CL_ADT_RES_APP_BASE`,定义 URI 路径 +2. **注册 BAdI `BADI_ADT_REST_RFC_APPLICATION`** — 链接到 Discovery Controller +3. **创建 Resource Application** — 继承 `CL_ADT_DISC_RES_APP_BASE` +4. **创建 Resource Controller** — 继承 `CL_ADT_REST_RESOURCE`,实现 GET/POST 方法 +5. **注册 BAdI `BADI_ADT_DISCOVERY_PROVIDER`** — 使资源可被发现 + +## 5. 架构演进:从 Eclipse 到 VS Code + +> [!success] 架构演进策略(2025年 SAP 官方博客) + +SAP 面临的核心挑战: +- **290万行** 客户端 UI 无关代码 +- **88个** 独特的对象类型编辑器(仅 SAP BTP ABAP 环境) + +### 解决方案 1:Language Server 复用 + +借鉴 Java VS Code 扩展的思路 — 将 Eclipse ADT 插件 "包装" 为 Language Server: + +``` +VS Code ←→ Language Server Protocol (LSP) ←→ Eclipse ADT 代码库 (2.9M 行) +``` + +- 无需从头重写客户端代码 +- Eclipse 和 VS Code 共享同一代码库 +- 连接任何 Eclipse 支持的 ABAP 服务器版本 + +### 解决方案 2:Server-Driven Development + +从 2020 年起,新对象类型采用 **服务器驱动开发** 模式: +- UI 逻辑完全在 ABAP 端定义(类似 Dynpro / Fiori Elements) +- 客户端只需 **两个渲染引擎**: + - **表单渲染器** — 用于表单类对象 + - **源码渲染器** — 用于源码类对象 +- 不再需要为每个 IDE 实现 88 个独立编辑器 + +## 6. 权限控制 + +| 权限对象 | 控制范围 | +|----------|---------| +| `S_ADT_RES` | 所有 ADT API 访问(端点级别) | +| `S_DEVELOP` | 对象读写操作 | +| `S_CTS_ADMI` | 传输管理 | +| `S_TRANSPRT` | 传输发布 | + +## 🔗 相关笔记 + +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] + +## 📚 参考来源 + +- [Behind the Design: How We Transformed the ABAP Development Tools](https://community.sap.com/t5/technology-blog-posts-by-sap/behind-the-design-how-we-transformed-the-abap-development-tools/ba-p/14258121) +- [Creating an ABAP in Eclipse Plug-in Using the ADT SDK](https://community.sap.com/t5/application-development-and-automation-blog-posts/creating-a-abap-in-eclipse-plug-in-using-the-adt-sdk-part-2/ba-p/13093582) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/02.查询代码-原理.md b/docs/adt/02.查询代码-原理.md new file mode 100644 index 0000000..e34e348 --- /dev/null +++ b/docs/adt/02.查询代码-原理.md @@ -0,0 +1,198 @@ +--- +title: 查询代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - search + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]]" +next: "[[sap-cli/03.修改代码-原理|03.修改代码 - 原理]]" +--- + +# 查询代码 — 原理 + +> [!abstract] 概述 +> ADT 提供三种主要的代码查询能力:**仓库对象搜索**(按名称/类型)、**源代码全文搜索**(按代码片段)和 **Where-Used 导航**(引用追踪)。这些功能全部通过 `/sap/bc/adt/repository` 端点的 REST API 实现。 + +## 1. 仓库对象搜索 (Repository Object Search) + +### 原理 + +通过 REST API 向 `/sap/bc/adt/repository/informationsystem/search` 发送 GET 请求,在后端 ABAP Repository 中搜索对象。 + +### REST API 调用 + +``` +GET /sap/bc/adt/repository/informationsystem/search + ?operation=quickSearch + &query=<搜索关键词> + &maxResults=50 + &objectType=<对象类型> (可选) +``` + +### 参数说明 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `operation` | 搜索操作类型 | `quickSearch` | +| `query` | 搜索关键词 | `ZCL_SALES*` | +| `maxResults` | 最大返回数量 | `50` | +| `objectType` | 对象类型过滤 | `CLAS`, `PROG`, `FUGR`, `TABL`, `DTEL`, `DOMA` | + +### 响应格式 + +```xml + + + +``` + +### 后端处理流程 + +``` +1. Eclipse 发送 GET 请求到 repository search 端点 +2. ICF Handler 将请求路由到 ABAP Repository Search 类 +3. 搜索引擎扫描 ABAP Repository(类似 SE80 搜索) +4. 返回匹配对象的元数据:名称、类型、URI、包名 +5. Eclipse 在 Project Explorer 中展示搜索结果 +6. 双击结果 → 通过 URI 加载对象的源代码编辑器 +``` + +### 对应的 SAP GUI 事务码 + +| ADT 搜索 | SAP GUI 等效 | +|----------|-------------| +| 快速搜索 | `SE80` — Repository Browser | +| 按类型搜索 | `SE24`(类) / `SE38`(程序) / `SE37`(函数) | +| 按包搜索 | `SE80` — Package 节点 | + +## 2. 源代码全文搜索 (ABAP Source Code Search) + +### 原理 + +> [!info] 前置条件 +> 源代码搜索需要激活业务功能 **`SRIS_SOURCE_SEARCH`**。 + +ADT 提供了一个 **搜索控制台**,允许在整个 ABAP 源码仓库中进行全文搜索,类似 IDE 中的 "Search in Files" 功能。 + +### 工作流程 + +``` +1. 用户在 Eclipse 搜索控制台输入代码片段 + ↓ +2. 客户端发送搜索请求到 ADT REST 端点 + ↓ +3. 后端搜索引擎执行全文本索引查找 + ↓ +4. 返回匹配的源码位置(对象名 + 行号) + ↓ +5. Eclipse 展示搜索结果,点击可直接跳转到编辑器 +``` + +### 搜索能力 + +- **任意代码片段搜索** — 搜索变量名、方法调用、SQL 语句等 +- **正则表达式支持** — 模式匹配搜索 +- **范围限定** — 可限定搜索范围(包、对象类型等) +- **直接导航** — 搜索结果可直接跳转到对应的开发对象 + +### 对应 SAP GUI 的优势 + +在传统 SAP GUI 中,**不存在等效的全文本源码搜索功能**。这是 ADT 相对于 SE80 的重大改进之一。 + +## 3. 对象浏览与导航 + +### Repository Browser + +``` +GET /sap/bc/adt/repository/informationsystem +``` + +浏览 ABAP Repository 的层级结构: +- **按包浏览** — 展开包 → 查看其中所有对象 +- **按类型浏览** — 按类、程序、表等类型分类 +- **按传输层浏览** — 按传输层组织查看 + +### 对象详情获取 + +``` +GET /sap/bc/adt/oo/classes/{class_name} +GET /sap/bc/adt/programs/programs/{program_name} +GET /sap/bc/adt/functions/groups/{group_name} +``` + +每个对象类型的详细信息通过对应的端点获取。 + +## 4. Where-Used 导航 + +### 原理 + +Where-Used 分析追踪对象在整个仓库中的引用关系。例如:查找哪些程序使用了某个类或方法。 + +### 处理流程 + +``` +1. 用户在编辑器中选中一个标识符(类名/方法名/变量) + ↓ +2. Eclipse 发送 Where-Used 请求到 Repository 端点 + ↓ +3. 后端 ABAP Runtime 执行交叉引用分析 + ↓ +4. 返回所有引用位置(对象 + 行号 + 列号) + ↓ +5. Eclipse 展示引用列表,支持直接导航 +``` + +### 对应的 SAP GUI 事务码 + +| ADT Where-Used | SAP GUI 等效 | +|----------------|-------------| +| 引用查找 | `SE80` → Where-Used List | +| 类型层次 | `SE24` → Superclass/Subclass | + +## 5. 数据预览 (Data Preview) + +### 原理 + +ADT 提供了表数据读取能力,可以替代传统的 SE16N 事务码。 + +### REST API 调用 + +``` +POST /sap/bc/adt/datapreview/{table_name} +Content-Type: application/xml + + + + * + 100 + +``` + +### 优势对比 + +| 特性 | SE16N (SAP GUI) | ADT Data Preview | +|------|-----------------|-------------------| +| 访问方式 | 事务码,需要 SAP GUI | REST API,任何 HTTP 客户端 | +| 数据格式 | ALV Grid(屏幕渲染) | 结构化 XML/JSON | +| 并行查询 | 不支持 | 支持多线程并发 | +| 自动化 | 困难 | 可编程访问 | +| 平台限制 | Windows + SAP GUI | 任何平台 | + +## 🔗 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] + +## 📚 参考来源 + +- [SAP Help Portal - Searching ABAP Source Code](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/searching-abap-source-code) +- [ABAP Source Code Search - SAP Community](https://community.sap.com/t5/application-development-blog-posts/abap-sourcecode-search/bc-p/13074593) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/03.修改代码-原理.md b/docs/adt/03.修改代码-原理.md new file mode 100644 index 0000000..1c8ff05 --- /dev/null +++ b/docs/adt/03.修改代码-原理.md @@ -0,0 +1,257 @@ +--- +title: 修改代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - edit + - lock + - activation + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/02.查询代码-原理|02.查询代码 - 原理]]" +next: "[[sap-cli/04.检查代码-原理|04.检查代码 - 原理]]" +--- + +# 修改代码 — 原理 + +> [!abstract] 核心流程 +> ADT 中修改代码遵循 **锁定 → 编辑 → 保存 → 解锁 → 激活** 的严格工作流。每一步都通过 REST API 调用对应的 ABAP 后端服务完成。 + +## 1. 完整工作流 + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ 1.锁定 │───▶│ 2.编辑 │───▶│ 3.保存 │───▶│ 4.解锁 │───▶│ 5.激活 │ +│ Lock │ │ Edit │ │ Save │ │ Unlock │ │ Activate│ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + POST lock GET source PUT source POST unlock POST activate + → lockHandle → 纯文本 + lockHandle + lockHandle → 激活结果 +``` + +> [!warning] 重要约束 +> - **必须先锁定** 才能修改代码,否则 PUT 请求会被拒绝 +> - **锁定句柄 (lockHandle)** 是贯穿整个编辑流程的关键凭证 +> - **必须激活** 后代码才会在运行时生效(保存 ≠ 激活) + +## 2. Step 1: 锁定对象 (Lock) + +### 原理 + +ABAP 使用 **乐观锁 (Optimistic Lock)** 机制。当开发者打开一个对象进行编辑时,ADT 向后端发送锁定请求,获取一个唯一的 `lockHandle`。 + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{class_name} + ?_action=LOCK + &accessMode=MODIFY +``` + +### 响应 + +```xml + +``` + +- 返回的 `LOCK_HANDLE` 是一个唯一标识符 +- 后续的写入操作必须携带此句柄 +- 锁定是 **会话级** 的,HTTP 会话断开后锁自动释放 + +### 后端处理 + +``` +1. ADT Framework 接收锁定请求 + ↓ +2. 调用 ABAP 锁定管理器 (ENQUEUE) + ↓ +3. 检查对象是否已被其他用户锁定 + ↓ +4. 如果未锁定 → 创建锁定条目,返回 lockHandle +5. 如果已锁定 → 返回错误(锁定冲突) +``` + +### 对应 SAP GUI + +| ADT 操作 | SAP GUI 等效 | +|----------|-------------| +| Lock 对象 | 打开 SE24/SE38 编辑模式时自动锁定 | + +## 3. Step 2: 读取源代码 (Read Source) + +### REST API 调用 + +``` +GET /sap/bc/adt/oo/classes/{class_name}/source/main +Accept: text/plain +``` + +### 对象路径模式 + +不同对象类型使用不同的路径: + +| 对象类型 | 路径模板 | +|----------|---------| +| 类 | `oo/classes/{name}/source/main` | +| 程序 | `programs/programs/{name}/source/main` | +| 函数模块 | `functions/groups/{group}/fmodules/{fm}/source/main` | +| CDS View | `ddic/cds/views/{name}/source/main` | + +### 响应 + +直接返回 **纯文本** 格式的 ABAP 源代码(Content-Type: `text/plain`)。 + +## 4. Step 3: 写入源代码 (Write Source) + +### REST API 调用 + +``` +PUT /sap/bc/adt/oo/classes/{class_name}/source/main +Content-Type: text/plain; charset=utf-8 +X-sap-adt-lockhandle: {lockHandle} + +<修改后的 ABAP 源代码> +``` + +> [!important] 关键要点 +> - 请求体是 **纯文本**(不是 XML) +> - **必须携带** `X-sap-adt-lockhandle` Header +> - 保存 ≠ 激活:代码已写入仓库但仍处于 **非活跃状态** + +### 后端处理 + +``` +1. 验证 lockHandle 是否有效 + ↓ +2. 将源代码写入 ABAP Repository 的非活跃版本 (Inactive Version) + ↓ +3. 对象状态变为 "Modified"(在 Project Explorer 中显示 * 标记) + ↓ +4. 记录到传输请求任务中(如果已分配) +``` + +### 与 SAP GUI 的对比 + +| 特性 | SAP GUI (SE80/SE38) | ADT (Eclipse) | +|------|---------------------|---------------| +| 保存行为 | 保存即激活(或保存为非活跃) | 保存 ≠ 激活(显式分离) | +| 锁定管理 | 隐式(打开编辑时自动锁定) | 显式(REST API 锁定/解锁) | +| 版本管理 | 版本数据库 | 本地历史 + 版本数据库 | + +## 5. Step 4: 解锁对象 (Unlock) + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{class_name} + ?_action=UNLOCK + &lockHandle={lockHandle} +``` + +解锁后,其他开发者可以锁定并编辑该对象。 + +## 6. Step 5: 激活对象 (Activate) + +### 原理 + +> [!tip] 激活 vs 保存 +> - **保存 (Save)**:将源代码写入仓库的非活跃版本 +> - **激活 (Activate)**:编译代码并生成可执行版本(Active Version) +> - 只有激活后的代码才能被运行时系统使用 + +### REST API 调用 + +``` +POST /sap/bc/adt/activation +Content-Type: application/xml + + + + + +``` + +### 后端激活流程 + +``` +1. ADT 激活框架接收激活请求 + ↓ +2. 执行预检查 (Pre-audit) + ├── 语法检查 (Syntax Check) + ├── 依赖检查(引用的对象是否已激活) + └── 权限检查 + ↓ +3. 如果预检查通过: + ├── 编译 ABAP 代码 → 生成 ABAP Load + ├── 更新 ABAP Repository 中的活跃版本 (Active Version) + ├── 触发 ATC 检查(如果配置了自动检查) + └── 将活跃版本写入传输请求 + ↓ +4. 如果预检查失败: + └── 返回错误列表(语法错误、缺失引用等) +``` + +### 对应 SAP GUI + +| ADT 操作 | SAP GUI 等效 | +|----------|-------------| +| 激活单个对象 | `SE80` → Activate (Ctrl+F3) | +| 批量激活 | `SE80` → Inactive Objects → Activate All | + +## 7. 完整 REST API 调用序列示例 + +以下是一个修改 ABAP 类的完整 REST API 调用序列: + +```python +# 1. 锁定对象 +lock_resp = POST("/sap/bc/adt/oo/classes/zcl_example", + _action="LOCK", accessMode="MODIFY") +lock_handle = parse_lock_handle(lock_resp) + +# 2. 读取当前源代码 +source = GET("/sap/bc/adt/oo/classes/zcl_example/source/main", + Accept="text/plain") + +# 3. 修改源代码(客户端操作) +modified_source = modify_source(source) + +# 4. 写入修改后的源代码 +PUT("/sap/bc/adt/oo/classes/zcl_example/source/main", + body=modified_source, + headers={"X-sap-adt-lockhandle": lock_handle}) + +# 5. 解锁对象 +POST("/sap/bc/adt/oo/classes/zcl_example", + _action="UNLOCK", lockHandle=lock_handle) + +# 6. 激活对象 +POST("/sap/bc/adt/activation", + body=activation_xml("ZCL_EXAMPLE", + "/sap/bc/adt/oo/classes/zcl_example")) +``` + +## 8. 错误处理 + +| 场景 | HTTP 状态码 | 处理方式 | +|------|-----------|---------| +| 对象已被锁定 | 403/409 | 提示用户等待或强制解锁 | +| CSRF Token 过期 | 403 | 自动重新获取 Token 并重试 | +| 语法错误 | 400 | 返回错误列表,阻止激活 | +| 权限不足 | 401/403 | 检查 S_DEVELOP 权限 | + +## 🔗 相关笔记 + +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] + +## 📚 参考来源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [erpl-adt (GitHub)](https://github.com/DataZooDE/erpl-adt) diff --git a/docs/adt/04.检查代码-原理.md b/docs/adt/04.检查代码-原理.md new file mode 100644 index 0000000..5531874 --- /dev/null +++ b/docs/adt/04.检查代码-原理.md @@ -0,0 +1,270 @@ +--- +title: 检查代码 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - ATC + - syntax-check + - code-quality + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/03.修改代码-原理|03.修改代码 - 原理]]" +next: "[[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]]" +--- + +# 检查代码 — 原理 + +> [!abstract] 概述 +> ADT 提供三层代码检查能力:**语法检查 (Syntax Check)**、**ABAP Test Cockpit (ATC)** 静态分析** 和 **单元测试执行**。检查结果通过 REST API 返回,在 Eclipse 中以结构化问题列表展示。 + +## 1. 三层代码检查体系 + +``` +┌──────────────────────────────────────────────────────┐ +│ Level 3: 单元测试 (ABAP Unit) │ +│ • 执行实际的测试代码 │ +│ • 验证运行时行为 │ +│ • REST: /sap/bc/adt/oo/classes/{name}/testruns │ +├──────────────────────────────────────────────────────┤ +│ Level 2: ATC 质量检查 (ABAP Test Cockpit) │ +│ • 静态代码分析(功能/性能/安全) │ +│ • 可在激活时自动触发 │ +│ • REST: /sap/bc/adt/atc/runs │ +├──────────────────────────────────────────────────────┤ +│ Level 1: 语法检查 (Syntax Check) │ +│ • 编译期错误检测 │ +│ • 激活前必须通过 │ +│ • REST: 集成在 activation 端点中 │ +└──────────────────────────────────────────────────────┘ +``` + +## 2. 语法检查 (Syntax Check) + +### 原理 + +语法检查是 **最低级别** 的代码检查,验证 ABAP 代码是否能够成功编译。它作为激活流程的一部分自动执行,也可以手动触发。 + +### 触发时机 + +| 场景 | 自动/手动 | 说明 | +|------|----------|------| +| 激活对象前 | 自动 | 激活框架首先执行语法检查 | +| 编辑器中保存后 | 可配置 | 可设置为保存时自动检查 | +| 手动触发 | 手动 | Ctrl+F2 或菜单 | + +### 工作流程 + +``` +1. 客户端触发语法检查请求 + ↓ +2. ADT Framework 调用 ABAP 编译器 (ABAP Compiler) + ↓ +3. 编译器分析源代码: + ├── 关键字拼写检查 + ├── 类型兼容性检查 + ├── 接口实现完整性检查 + └── 引用对象存在性检查 + ↓ +4. 返回检查结果: + ├── Errors (错误) — 阻止激活 + ├── Warnings (警告) — 不阻止但建议修复 + └── Information (信息) — 代码改进建议 + ↓ +5. Eclipse 在编辑器中以标记形式展示 + (红色波浪线 = Error,黄色 = Warning) +``` + +### 与 SAP GUI 的对比 + +| ADT 语法检查 | SAP GUI 等效 | +|-------------|-------------| +| 自动检查 + 编辑器标记 | `SE38` → Check (Ctrl+F2) | +| 保存时可自动检查 | 需要手动触发 | +| 实时错误提示 | 无实时检查 | + +## 3. ABAP Test Cockpit (ATC) + +### 原理 + +ATC 是 SAP 的 **标准化代码质量检查工具**,提供比语法检查更深入的静态分析能力,涵盖功能正确性、性能优化和安全合规。 + +### REST API 调用 + +#### 创建 ATC 运行 + +``` +POST /sap/bc/adt/atc/runs +Content-Type: application/xml + + + + + + + + + + + +``` + +#### 获取 ATC 结果 + +``` +GET /sap/bc/adt/atc/runs/{run_id}/results +``` + +### 响应格式 + +```xml + + + + +``` + +### ATC 检查类别 + +| 类别 | 说明 | 示例检查项 | +|------|------|-----------| +| **功能检查** | 逻辑错误、空引用 | 未处理的异常、未使用的变量 | +| **性能检查** | 性能反模式 | SELECT *、嵌套 SELECT、缺少索引 | +| **安全检查** | 安全漏洞 | SQL 注入、权限检查缺失 | +| **编程规范** | 编码标准 | 命名规范、注释要求 | +| **Cloud Readiness** | 云兼容性 | 不允许的语句/对象 | + +### ATC 运行模式 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 模式 1: 手动检查 │ +│ 开发者右键 → Run ATC Check │ +│ → 立即执行,结果展示在 ATC Results 视图中 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 2: 激活时自动检查 │ +│ 配置 Preferences → ABAP → ATC │ +│ → 每次激活时自动触发 ATC │ +│ → 发现问题时提示开发者 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 3: 传输发布时检查 │ +│ 传输发布前自动运行 ATC │ +│ → 基于 Baseline 只检查新增/修改的代码 │ +│ → 确保不会将质量问题引入生产环境 │ +├──────────────────────────────────────────────────────────┤ +│ 模式 4: 远程代码分析 (Remote Code Analysis) │ +│ 在中央 ATC 系统上分析代码 │ +│ → 使用最新检查规则 │ +│ → 跨系统的代码质量监控 │ +└──────────────────────────────────────────────────────────┘ +``` + +### ATC Baseline 概念 + +> [!info] Baseline 的作用 +> Baseline 允许团队将现有代码标记为"已接受",只对**新增或修改的代码**运行检查。这避免了在遗留代码上产生大量已知问题。 + +``` +首次使用: + 1. 对整个仓库运行 ATC → 生成 Baseline + 2. Baseline 记录所有已知问题 + 3. 后续检查只报告 Baseline 之后新增的问题 + +传输检查: + 1. 传输发布时运行 ATC + 2. 与 Baseline 比对 + 3. 只有新增问题会阻止传输发布 +``` + +## 4. ABAP Unit 测试 + +### 原理 + +ABAP Unit 是 ABAP 的内置单元测试框架。测试方法使用 `FOR TESTING` 标记,在专门的测试会话中执行。 + +### REST API 调用 + +``` +POST /sap/bc/adt/oo/classes/{test_class_name}/testruns +``` + +### 后端执行流程 + +``` +1. ADT 接收测试执行请求 + ↓ +2. ABAP Runtime 创建测试隔离环境 + ↓ +3. 执行标记为 FOR TESTING 的方法 + ↓ +4. 收集测试结果: + ├── Passed (通过) + ├── Failed (失败) + 失败原因 + └── Error (错误) + 异常信息 + ↓ +5. 返回结果给客户端 +``` + +## 5. 代码检查与 CI/CD 集成 + +### 自动化检查流水线 + +``` +代码修改 → 语法检查 → ATC 检查 → ABAP Unit → 传输发布 + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + 保存成功 无错误 无严重问题 全部通过 发布到目标系统 +``` + +### 通过 REST API 实现自动化 + +```python +# 完整的自动检查流程 +def automated_code_check(client, object_uri): + # 1. 语法检查(通过激活预检) + activation_result = activate_object(client, object_uri) + if activation_result.get("errors"): + return "BLOCKED: Syntax errors" + + # 2. ATC 检查 + atc_result = run_atc_check(client, object_uri) + critical_findings = [f for f in atc_result["findings"] + if f["priority"] == "1"] + if critical_findings: + return "BLOCKED: Critical ATC findings" + + # 3. 单元测试 + test_result = run_unit_tests(client, test_class_uri) + if test_result["failed"] > 0: + return "BLOCKED: Unit test failures" + + return "PASSED: All checks green" +``` + +## 6. Eclipse 中的检查结果展示 + +| 视图 | 说明 | +|------|------| +| **Problems View** | 语法检查和编译错误 | +| **ATC Results View** | ATC 检查发现,按严重级别分组 | +| **ABAP Unit Results** | 单元测试执行结果 | +| **Editor Markers** | 编辑器中的行内错误/警告标记 | + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 激活流程中集成语法检查 +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] — 传输发布时触发 ATC + +## 📚 参考来源 + +- [SAP Help Portal - Checking Quality of ABAP Code with ATC](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/checking-quality-of-abap-code-with-atc) +- [Remote Code Analysis in ATC](https://community.sap.com/t5/application-development-and-automation-blog-posts/remote-code-analysis-in-abap-test-cockpit-atc-faq/ba-p/13320130) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/05.提交与传输-原理.md b/docs/adt/05.提交与传输-原理.md new file mode 100644 index 0000000..03dbdae --- /dev/null +++ b/docs/adt/05.提交与传输-原理.md @@ -0,0 +1,317 @@ +--- +title: 提交与传输 - 原理 +created: 2026-05-18 +tags: + - SAP + - ADT + - transport + - CTS + - version-management + - REST +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/04.检查代码-原理|04.检查代码 - 原理]]" +--- + +# 提交与传输 — 原理 + +> [!abstract] 核心概念 +> SAP 中的 "提交代码" 与 Git 等版本控制系统不同。ABAP 使用 **CTS (Change and Transport System)** 来管理代码变更。代码修改被记录在 **传输请求 (Transport Request)** 中,通过传输请求将变更从开发系统 → 测试系统 → 生产系统进行迁移。 + +## 1. 传输管理概念模型 + +### 传输系统架构 + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 开发系统 │ │ 测试系统 │ │ 生产系统 │ +│ (DEV) │ │ (QAS) │ │ (PRD) │ +│ │ │ │ │ │ +│ 创建/修改 │────▶│ 测试/验证 │────▶│ 部署上线 │ +│ 代码 │ │ 代码 │ │ 代码 │ +└──────┬───────┘ └──────────────┘ └──────────────┘ + │ + │ 导出 + ▼ +┌──────────────┐ +│ 传输目录 │ +│ (Transport │ +│ Directory) │ +│ │ +│ .TR 文件 │ +│ 数据文件 │ +└──────────────┘ + │ + │ 导入 + ▼ + 目标系统 (QAS/PRD) +``` + +### 传输请求结构 + +``` +传输请求 (Transport Request - TR) +├── 请求头 (Request Header) +│ ├── 编号: K9XXXXXX +│ ├── 类型: K (Workbench) / W (Customizing) / T (Transport of Copies) +│ ├── 描述: "Implement sales order validation" +│ ├── 所有者: DEVELOPER01 +│ ├── 状态: D (Modifiable) → R (Released) +│ └── 目标系统: QAS +│ +├── 任务 (Task) — 可以有多个 +│ ├── 编号: K9XXXXYY +│ ├── 所有者: DEVELOPER01 +│ └── 状态: D → R +│ +└── 对象列表 (Object List) + ├── ZCL_SALES_ORDER (CLAS) + ├── Z_SALES_REPORT (PROG) + └── Z_SALES_TABLE (TABL) +``` + +## 2. ADT 中的传输工作流 + +### 完整流程 + +``` +1. 创建传输请求 + ↓ +2. 将对象分配到传输请求 + ↓ +3. 开发/修改代码 + ↓ +4. 激活代码 + ↓ +5. (可选) 运行 ATC 检查 + ↓ +6. 发布传输请求 + ↓ +7. 导入到目标系统 +``` + +## 3. REST API 详解 + +### 3.1 列出传输请求 + +``` +GET /sap/bc/adt/cts/transportrequests + ?user={username} + &status={D|R} +``` + +| 参数 | 说明 | +|------|------| +| `user` | 按所有者过滤 | +| `status` | `D` = 可修改, `R` = 已发布 | + +### 响应 + +```xml + + + +``` + +### 3.2 创建传输请求 + +``` +POST /sap/bc/adt/cts/transportrequests +Content-Type: application/xml + + + +``` + +| 字段 | 说明 | +|------|------| +| `type="K"` | Workbench 请求(程序、类等) | +| `type="W"` | Customizing 请求(配置数据) | +| `type="T"` | Transport of Copies(复制传输) | + +### 3.3 发布传输请求 + +``` +POST /sap/bc/adt/cts/transportrequests/{request_number}/newreleasejobs +``` + +### 后端处理流程 + +``` +1. ADT CTS 端点接收发布请求 + ↓ +2. 传输系统执行预检查: + ├── 检查所有任务是否已发布 + ├── 检查所有对象是否已激活 + ├── (可选) 运行 ATC 检查 + └── 检查是否有未释放的对象 + ↓ +3. 将传输请求导出到传输目录 + ├── 生成数据文件(包含对象内容) + └── 生成控制文件(包含元数据) + ↓ +4. 请求状态变为 R (Released) + ↓ +5. 传输管理系统 (tp / STMS) 将请求导入目标系统 +``` + +### 3.4 Transport of Copies + +> [!tip] Transport of Copies +> Transport of Copies (ToC) 是一种特殊类型的传输,用于在不改变原始传输请求的情况下将代码复制到目标系统。常用于紧急修复或临时部署。 + +``` +POST /sap/bc/adt/cts/transportrequests/{source_request}/tocopies +``` + +## 4. 对象分配到传输请求 + +### 原理 + +当开发者修改一个对象时,系统会提示选择或创建传输请求。对象随后被记录到传输请求的任务中。 + +### 分配方式 + +``` +┌────────────────────────────────────────────────────┐ +│ 方式 1: 自动提示 │ +│ 修改对象时 → Eclipse 弹出对话框 → 选择/创建 TR │ +├────────────────────────────────────────────────────┤ +│ 方式 2: 拖拽分配 │ +│ 在 Project Explorer 中拖拽对象到 TR │ +├────────────────────────────────────────────────────┤ +│ 方式 3: 批量分配 │ +│ 选择多个对象 → Assign to Transport Request │ +└────────────────────────────────────────────────────┘ +``` + +## 5. 版本管理 + +### ABAP 版本管理机制 + +ABAP 内置了 **版本数据库 (Version Database)**,每次激活对象时自动创建一个版本。 + +### ADT 中的版本比较 + +``` +┌────────────────────────────────────────────┐ +│ 版本类型 │ +├────────────────────────────────────────────┤ +│ 活跃版本 (Active Version) │ +│ → 当前系统中正在运行的代码版本 │ +│ 非活跃版本 (Inactive Version) │ +│ → 已保存但未激活的修改版本 │ +│ 传输版本 (Transport Version) │ +│ → 传输请求中记录的版本 │ +│ 历史版本 (Historical Version) │ +│ → 版本数据库中保存的所有历史版本 │ +└────────────────────────────────────────────┘ +``` + +### 比较操作 + +| 操作 | 说明 | +|------|------| +| Compare with Active Version | 当前编辑 vs 活跃版本 | +| Compare with Local History | 当前 vs 本地历史(客户端缓存) | +| Compare with Transport Version | 当前 vs 传输中的版本 | +| Version History | 查看所有历史版本 | + +## 6. ADT 传输管理 vs SAP GUI 传输管理 + +| 功能 | SAP GUI | ADT Eclipse | +|------|---------|-------------| +| 创建 TR | `SE01` / `SE09` | 右键 → New → Transport Request | +| 查看 TR | `SE01` / `SE09` | Transport Requests 视图 | +| 发布 TR | `SE01` → Release | 右键 → Release | +| 查看 TR 内容 | `SE01` → Display | 双击 TR → 展开对象列表 | +| TR 详情 | `SE03` | Properties 视图 | +| 版本比较 | `SE80` → Version Management | 右键 → Compare With | +| 批量操作 | `SE01` | 多选 + 上下文菜单 | + +## 7. 传输发布与 ATC 集成 + +> [!important] 传输发布质量门禁 +> 传输发布时可以配置 **ATC 质量门禁**,确保代码质量达标后才能发布到目标系统。 + +### 配置流程 + +``` +1. 在 SAP 系统中配置 ATC 传输检查 + (事务码: SATC -> 配置传输发布检查) + ↓ +2. 设置检查规则: + ├── 设置 Baseline + ├── 配置检查范围 + └── 设置严重级别阈值 + ↓ +3. 传输发布时自动触发 ATC + ↓ +4. 只有通过检查的传输才能继续发布 + (或配置为仅警告不阻止) +``` + +## 8. 通过 REST API 实现自动化传输管理 + +### Python 自动化示例 + +```python +def automated_transport_workflow(client, object_uri, description): + """自动化传输工作流""" + + # 1. 创建传输请求 + tr_number = create_transport_request( + client, + description=description, + target_system="QAS" + ) + + # 2. 修改代码(参见 03.修改代码-原理) + lock_handle = lock_object(client, object_uri) + # ... 修改源代码 ... + write_source(client, object_uri, modified_source, lock_handle) + unlock_object(client, object_uri, lock_handle) + + # 3. 激活代码 + activate_object(client, object_uri) + + # 4. 运行 ATC 检查 + atc_result = run_atc_check(client, object_uri) + critical = [f for f in atc_result["findings"] + if f["priority"] == "1"] + if critical: + raise Exception("ATC check failed") + + # 5. 发布传输请求 + release_transport(client, tr_number) + + return tr_number +``` + +## 9. 常见问题与解决方案 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 无法创建 TR | 缺少 S_CTS_ADMI 权限 | 联系 BASIS 分配权限 | +| 发布失败 | 对象未激活 | 先激活所有对象 | +| ATC 阻止发布 | 存在严重问题 | 修复 ATC 发现或更新 Baseline | +| 传输导入失败 | 目标系统缺少依赖 | 确保依赖对象已在目标系统激活 | +| 锁定冲突 | 其他用户正在编辑 | 等待或请求解锁 | + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 代码修改 → 传输请求 +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] — ATC 检查与传输发布集成 + +## 📚 参考来源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [ABAP Tools – Work with Eclipse (Transport) – Software-Heroes](https://software-heroes.com/en/blog/abap-tools-work-with-eclipse-transport) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [Transporting ABAP and Non-ABAP Objects (SAP Community Call)](https://www.youtube.com/watch?v=FpAuj8QD-rM) +- [Version Management in ABAP in Eclipse - SAP Community](https://community.sap.com/t5/application-development-discussions/version-management-in-abap-in-eclipse/m-p/12611386) diff --git a/docs/adt/06.ADT测试代码.md b/docs/adt/06.ADT测试代码.md new file mode 100644 index 0000000..b79ede7 --- /dev/null +++ b/docs/adt/06.ADT测试代码.md @@ -0,0 +1,336 @@ +--- +title: ADT 测试代码 — 读取程序源代码 +created: 2026-05-19 +tags: + - SAP + - ADT + - REST + - python + - test-code +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 测试代码 — 通过 REST API 读取程序源代码 + +> [!abstract] 概述 +> 通过 ADT REST API 连接 SAP 系统,测试连接、服务发现、读取程序源代码、搜索仓库对象。 +> 参见:[[sap-cli/02.查询代码-原理]] + +## 运行方式 + +```bash +# 安装依赖 +pip install requests + +# 运行测试 +python sap-cli/test_adt_client.py +``` + +## 测试结果 + +> [!success] 2026-05-19 测试通过 +> - 连接验证: HTTP 200 ✅ +> - 服务发现: 311 个可用服务 ✅ +> - 读取源代码: `ZIDTR_IMPORT_ACCOUNTING_ORDER` (37,655 字符 / 1,096 行) ✅ +> - 仓库搜索: quickSearch 返回 0 结果 ⚠️ + +## 完整代码 + +```python +""" +SAP ADT REST Client — 测试脚本 +通过 ADT REST API 连接 SAP 系统并获取程序源代码 + +参考: [[sap-cli/02.查询代码-原理]] + [[sap-cli/01.ADT架构与通信原理]] + +端点: /sap/bc/adt/programs/programs/{name}/source/main +""" + +import requests +from urllib.parse import urljoin +import xml.etree.ElementTree as ET +import sys +import json +import io + +# 修复 Windows 控制台编码问题 +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + + +# ============================================================ +# 配置 — 来自 [[sap-cli/README]] 测试服务器信息 +# ============================================================ +ADT_BASE_URL = "http://support.learningleader.com.cn:55955/sap/bc/adt" +SAP_CLIENT = "100" +USER = "admin2" +PASSWORD = "654321" +PROGRAM_NAME = "ZIDTR_IMPORT_ACCOUNTING_ORDER" + + +class ADTClient: + """简易 ADT REST 客户端""" + + CSRF_HEADER = "x-csrf-token" + CSRF_FETCH = "Fetch" + + def __init__(self, base_url: str, client: str, user: str, password: str): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.auth = (user, password) + self.session.headers.update({ + "Accept": "*/*", + "Accept-Language": "EN", + "sap-client": client, + }) + # 忽略 SSL 警告(测试环境可能是自签名证书) + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecureRequestWarning + ) + self.session.verify = False + self._csrf_token: str | None = None + + def _url(self, path: str) -> str: + return f"{self.base_url}/{path}" + + def fetch_csrf_token(self) -> str: + """获取 CSRF Token — 写操作必需""" + print(" → 正在获取 CSRF Token...") + resp = self.session.get( + self._url("discovery"), + headers={self.CSRF_HEADER: self.CSRF_FETCH}, + ) + resp.raise_for_status() + self._csrf_token = resp.headers.get(self.CSRF_HEADER, "") + print(f" ✓ CSRF Token 获取成功: {self._csrf_token[:20]}...") + return self._csrf_token + + def get(self, path: str, **kwargs) -> requests.Response: + """发送 GET 请求""" + resp = self.session.get(self._url(path), **kwargs) + resp.raise_for_status() + return resp + + +def test_connection(client: ADTClient) -> bool: + """测试 1: 验证 ADT 连接""" + print("\n" + "=" * 60) + print("测试 1: 验证 ADT 连接") + print("=" * 60) + print(f" URL: {client.base_url}") + print(f" User: {client.session.auth[0]}") + print(f" Client: {client.session.headers.get('sap-client')}") + + try: + resp = client.session.get(client._url("discovery")) + print(f" → HTTP Status: {resp.status_code}") + if resp.status_code == 401: + print(f" ✗ 认证失败 (401 Unauthorized)") + print(f" 响应体: {resp.text[:300]}") + return False + resp.raise_for_status() + print(f" ✓ 连接成功! HTTP {resp.status_code}") + print(f" ✓ Content-Type: {resp.headers.get('content-type', 'N/A')}") + return True + except requests.exceptions.HTTPError as e: + print(f" ✗ HTTP 错误: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f" 响应体: {e.response.text[:300]}") + return False + except requests.exceptions.ConnectionError as e: + print(f" ✗ 网络连接失败: {e}") + return False + + +def test_discovery(client: ADTClient) -> list[dict]: + """测试 2: 服务发现 — 列出可用的 ADT 服务""" + print("\n" + "=" * 60) + print("测试 2: 服务发现 (Discovery)") + print("=" * 60) + + try: + resp = client.get("discovery") + root = ET.fromstring(resp.content) + + # 解析 Atom Feed 中的服务集合 + ns = { + "app": "http://www.w3.org/2007/app", + "atom": "http://www.w3.org/2005/Atom", + } + + services = [] + for collection in root.findall(".//app:collection", ns): + href = collection.attrib.get("href", "") + title_el = collection.find("atom:title", ns) + title = title_el.text if title_el is not None else "" + services.append({"href": href, "title": title}) + + print(f" ✓ 发现 {len(services)} 个可用服务:") + for svc in services[:15]: # 只显示前 15 个 + print(f" • {svc['title']}: {svc['href']}") + if len(services) > 15: + print(f" ... 还有 {len(services) - 15} 个服务") + + return services + except Exception as e: + print(f" ✗ 服务发现失败: {e}") + return [] + + +def test_read_program_source(client: ADTClient, program_name: str) -> str | None: + """测试 3: 读取程序源代码""" + print("\n" + "=" * 60) + print(f"测试 3: 读取程序源代码 ({program_name})") + print("=" * 60) + + # 尝试多种可能的端点路径 + endpoints = [ + f"programs/programs/{program_name.lower()}/source/main", + f"programs/programs/{program_name}/source/main", + ] + + for endpoint in endpoints: + try: + print(f" → 尝试端点: GET {endpoint}") + resp = client.get( + endpoint, + headers={"Accept": "text/plain"}, + ) + source = resp.text + + print(f" ✓ 源代码获取成功!") + print(f" ✓ 程序名: {program_name}") + print(f" ✓ 源代码长度: {len(source)} 字符") + print(f" ✓ 行数: {len(source.splitlines())}") + + # 显示前 30 行 + lines = source.splitlines() + print(f"\n ┌─── 源代码 (前 30 行) ──────────────────────") + for i, line in enumerate(lines[:30], 1): + print(f" │ {i:4d} | {line}") + if len(lines) > 30: + print(f" │ ... 省略剩余 {len(lines) - 30} 行 ...") + print(f" └────────────────────────────────────────────") + + return source + + except requests.exceptions.HTTPError as e: + print(f" ✗ HTTP 错误: {e.response.status_code} {e.response.reason}") + if e.response.status_code == 404: + print(f" (程序不存在或端点路径不正确,尝试下一个...)") + continue + else: + print(f" 响应内容: {e.response.text[:200]}") + return None + except Exception as e: + print(f" ✗ 读取失败: {e}") + return None + + print(f" ✗ 所有端点尝试均失败") + return None + + +def test_search_object(client: ADTClient, program_name: str) -> list[dict]: + """测试 4: 搜索仓库对象""" + print("\n" + "=" * 60) + print(f"测试 4: 搜索仓库对象 ({program_name})") + print("=" * 60) + + try: + resp = client.get( + "repository/informationsystem/search", + params={ + "operation": "quickSearch", + "query": program_name, + "maxResults": "10", + }, + ) + + ns = "http://www.sap.com/adt/repository/informationsystem" + root = ET.fromstring(resp.content) + + results = [] + for obj in root.iter(f"{{{ns}}}object"): + results.append({ + "name": obj.attrib.get("name", ""), + "type": obj.attrib.get("type", ""), + "uri": obj.attrib.get("uri", ""), + "package": obj.attrib.get("packageName", ""), + }) + + print(f" ✓ 搜索完成,找到 {len(results)} 个结果:") + for r in results: + print(f" • [{r['type']}] {r['name']} (Package: {r['package']})") + print(f" URI: {r['uri']}") + + return results + + except Exception as e: + print(f" ✗ 搜索失败: {e}") + return [] + + +def main(): + print("=" * 60) + print(" SAP ADT REST API 测试脚本") + print(" 参考: [[sap-cli/02.查询代码-原理]]") + print("=" * 60) + + # 创建客户端 + client = ADTClient( + base_url=ADT_BASE_URL, + client=SAP_CLIENT, + user=USER, + password=PASSWORD, + ) + + # 测试 1: 连接验证 + if not test_connection(client): + print("\n❌ 连接失败,请检查服务器配置和网络连接。") + sys.exit(1) + + # 测试 2: 服务发现 + test_discovery(client) + + # 测试 3: 读取程序源代码 + source = test_read_program_source(client, PROGRAM_NAME) + + # 测试 4: 搜索仓库对象 + test_search_object(client, PROGRAM_NAME) + + # 汇总结果 + print("\n" + "=" * 60) + print(" 测试结果汇总") + print("=" * 60) + if source: + print(" ✅ 程序源代码获取成功!") + print(f" 程序: {PROGRAM_NAME}") + print(f" 长度: {len(source)} 字符, {len(source.splitlines())} 行") + else: + print(" ⚠️ 程序源代码获取失败,请确认程序名称是否正确") + print("=" * 60) + + +if __name__ == "__main__": + main() +``` + +## 关键 API 端点速查 + +| 操作 | HTTP 方法 | 端点 | Accept | +|------|----------|------|--------| +| 服务发现 | `GET` | `/sap/bc/adt/discovery` | `*/*` | +| 读取程序源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | `text/plain` | +| 读取类源码 | `GET` | `/sap/bc/adt/oo/classes/{name}/source/main` | `text/plain` | +| 仓库搜索 | `GET` | `/sap/bc/adt/repository/informationsystem/search` | `*/*` | + +> [!note] 注意事项 +>> - discovery 端点的 Accept 必须用 `*/*`,用 `application/xml` 会返回 406 +> - 程序名在 URL 中使用**小写** + +## 🔗 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] +- [[sap-cli/02.查询代码-原理|02.查询代码 - 原理]] +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] diff --git a/docs/adt/07.ADT代码同步工具.md b/docs/adt/07.ADT代码同步工具.md new file mode 100644 index 0000000..ec1c3a3 --- /dev/null +++ b/docs/adt/07.ADT代码同步工具.md @@ -0,0 +1,352 @@ +--- +title: ADT 代码同步工具 — 使用指南 +created: 2026-05-19 +tags: + - SAP + - ADT + - REST + - python + - sync + - pull + - push + - activate +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +prev: "[[sap-cli/06.ADT测试代码|06.ADT 测试代码]]" +--- + +# ADT 代码同步工具 — 使用指南 + +> [!abstract] 概述 +> 通过 `test_adt_client.py` 脚本实现本地与 SAP 系统之间的 ABAP 代码双向同步。支持拉取代码到本地编辑、修改后推送回 SAP 并自动激活。 +> +> 完整工作流遵循 [[sap-cli/03.修改代码-原理]] 中描述的 **锁定 → 编辑 → 保存 → 解锁 → 激活** 流程。 + +## 1. 快速开始 + +### 前置条件 + +```bash +pip install requests +``` + +### 基本用法 + +```bash +# 拉取代码(从 SAP 下载到本地) +python sap-cli/test_adt_client.py pull + +# 推送代码(从本地上传到 SAP 并激活) +python sap-cli/test_adt_client.py push +``` + +### 配置修改 + +脚本顶部的配置区域: + +```python +ADT_BASE_URL = "http://support.learningleader.com.cn:55955" # SAP 服务器地址 +SAP_CLIENT = "100" # SAP 客户端 +USER = "admin2" # 用户名 +PASSWORD = "654321" # 密码 +PROGRAM_NAME = "ZIDTR_IMPORT_ACCOUNTING_ORDER" # 程序名 +``` + +## 2. 拉取代码 (Pull) + +### 流程 + +``` +登录 → GET 源代码 → 保存到本地 .abap 文件 +``` + +### REST API 调用序列 + +| 步骤 | HTTP 方法 | URL | 说明 | +|------|----------|-----|------| +| 登录 | `GET` | `/sap/bc/adt/compatibility/graph` | 获取 CSRF Token | +| 读取源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | Accept: text/plain | + +### 关键请求头 + +```http +GET /sap/bc/adt/programs/programs/zidtr_import_accounting_order/source/main +Accept: text/plain +x-csrf-token: {token} +X-sap-adt-sessiontype: stateless +sap-client: 100 +``` + +### 输出 + +源代码保存到脚本同目录下的 `{程序名小写}.abap` 文件,例如: + +``` +sap-cli/zidtr_import_accounting_order.abap +``` + +> [!tip] 格式说明 +> SAP ADT API 返回的源代码每行之间可能插入多余空行。脚本已内置格式优化逻辑,自动清理连续空行。 + +## 3. 推送代码 (Push) + +### 完整流程 + +``` +登录 → 查询传输请求 → 锁定对象 → 写入源代码 → 解锁对象 → 激活对象 +``` + +对应 [[sap-cli/03.修改代码-原理]] 中的完整工作流: + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ 1.锁定 │───▶│ 2.写入 │───▶│ 3.解锁 │───▶│ 4.激活 │ +│ Lock │ │ Write │ │ Unlock │ │ Activate│ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ +``` + +### REST API 调用序列 + +#### Step 0: 登录获取 CSRF Token + +```http +GET /sap/bc/adt/compatibility/graph +x-csrf-token: fetch +X-sap-adt-sessiontype: stateless +``` + +响应头中返回 CSRF Token,后续所有写操作必须携带。 + +#### Step 1: 查询传输请求 + +```http +GET /sap/bc/adt/cts/transportrequests +Accept: application/vnd.sap.adt.transportorganizer.v1+xml +``` + +响应 XML 中解析第一个状态为 `D`(可修改)的传输请求号。 + +响应示例: + +```xml + +``` + +> [!important] 传输请求号 (corrNr) +> 传输请求号是代码写入 SAP 的必要参数。脚本自动查询并使用第一个可修改的请求。 +> +> XML 属性名带有命名空间前缀:`{http://www.sap.com/cts/adt/tm}number` + +#### Step 2: 锁定对象 + +```http +POST /sap/bc/adt/programs/programs/{name} +?_action=LOCK +&accessMode=MODIFY +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} +``` + +响应: + +```xml + +``` + +> [!warning] 会话状态 +> 锁定操作**必须在 stateful 会话**中执行(`X-sap-adt-sessiontype: stateful`)。 +> 登录端点必须使用 `/sap/bc/adt/compatibility/graph`(不是 `/discovery`),否则锁定会失败。 + +#### Step 3: 写入源代码 + +```http +PUT /sap/bc/adt/programs/programs/{name}/source/main +?lockHandle={lockHandle} +&corrNr={传输请求号} +Content-Type: text/plain; charset=utf-8 +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} + +{ABAP 源代码内容} +``` + +> [!important] 关键参数 +> - **lockHandle**:来自锁定步骤的返回值,作为**查询参数**传递 +> - **corrNr**:传输请求号,也是查询参数 +> - 请求体是**纯文本**(不是 XML),UTF-8 编码 + +#### Step 4: 解锁对象 + +```http +POST /sap/bc/adt/programs/programs/{name} +?_action=UNLOCK +&lockHandle={lockHandle} +X-sap-adt-sessiontype: stateful +x-csrf-token: {token} +``` + +#### Step 5: 激活对象 + +```http +POST /sap/bc/adt/activation +?method=activate +&preauditRequested=true +Content-Type: application/xml +x-csrf-token: {token} + + + + + +``` + +激活响应可能包含错误消息: + +```xml + + + Type "XXX" is unknown. + + +``` + +| type | 含义 | +|------|------| +| `E` | 错误 — 阻止激活 | +| `W` | 警告 — 不阻止 | +| `I` | 信息 — 不阻止 | +| `S` | 成功 | + +> [!note] 空响应 +> 激活成功时可能返回空响应体(HTTP 200),这表示激活无错误。 + +## 4. 传输请求详解 + +### 查询可用的传输请求 + +```http +GET /sap/bc/adt/cts/transportrequests +Accept: application/vnd.sap.adt.transportorganizer.v1+xml +``` + +### 响应结构 + +```xml + + + + + + +``` + +### 属性说明 + +| 属性 | 值 | 说明 | +|------|-----|------| +| `status` | `D` | 可修改 (Modifiable) | +| `status` | `R` | 已发布 (Released) | +| `number` | `DEVK901360` | 传输请求号 | +| `desc` | 文本描述 | 请求描述 | + +### XML 解析注意事项 + +属性名带有命名空间前缀,解析时需要使用完整名称: + +```python +tm_ns = "http://www.sap.com/cts/adt/tm" +for req in root.findall(f".//{{{tm_ns}}}request"): + number = req.attrib.get(f"{{{tm_ns}}}number", "") # 不是 "number" + status = req.attrib.get(f"{{{tm_ns}}}status", "") # 不是 "status" +``` + +## 5. 日志系统 + +每次执行 push/pull 操作时,详细的 API 请求/响应日志会写入: + +``` +sap-cli/adt_client.log +``` + +### 日志内容 + +``` +2026-05-19 10:57:56 | INFO | REQUEST: POST http://.../activation +2026-05-19 10:57:56 | INFO | PARAMS: {'method': 'activate', 'preauditRequested': 'true'} +2026-05-19 10:57:56 | INFO | HEADER: x-csrf-token = xxx... +2026-05-19 10:57:56 | INFO | BODY: +2026-05-19 10:57:56 | INFO | RESPONSE [ACTIVATE]: HTTP 200 OK +2026-05-19 10:57:56 | INFO | BODY (xml): ... +2026-05-19 10:57:56 | INFO | Activation msg [E] line=1: Type "XXX" is unknown. +2026-05-19 10:57:56 | INFO | Activation summary: 1 errors, 0 warnings, 0 info +2026-05-19 10:57:56 | WARNING | Activation has ERRORS - object may NOT be active! +``` + +### 排查激活失败 + +1. 查看 `adt_client.log` 中 `[ACTIVATE]` 部分 +2. 搜索 `Activation msg [E]` 找到错误消息 +3. 错误消息中的 `line` 是语句序号(不是源文件行号) +4. `href` 中的 `start=XXX` 是字符偏移量,可用于定位源码位置 + +## 6. 完整 API 端点速查 + +### 通用端点 + +| 操作 | 方法 | 端点 | 关键参数 | +|------|------|------|---------| +| 登录/CSRF | `GET` | `/sap/bc/adt/compatibility/graph` | `x-csrf-token: fetch` | +| 服务发现 | `GET` | `/sap/bc/adt/discovery` | — | +| 激活 | `POST` | `/sap/bc/adt/activation` | `method=activate` | + +### 程序 (PROG) 端点 + +| 操作 | 方法 | 端点 | 关键参数 | +|------|------|------|---------| +| 读取源码 | `GET` | `/sap/bc/adt/programs/programs/{name}/source/main` | `Accept: text/plain` | +| 锁定 | `POST` | `/sap/bc/adt/programs/programs/{name}` | `_action=LOCK` | +| 写入源码 | `PUT` | `/sap/bc/adt/programs/programs/{name}/source/main` | `lockHandle`, `corrNr` | +| 解锁 | `POST` | `/sap/bc/adt/programs/programs/{name}` | `_action=UNLOCK` | + +### 类 (CLASS) 端点 + +| 操作 | 方法 | 端点 | +|------|------|------| +| 读取源码 | `GET` | `/sap/bc/adt/oo/classes/{name}/source/main` | +| 锁定 | `POST` | `/sap/bc/adt/oo/classes/{name}` | +| 写入源码 | `PUT` | `/sap/bc/adt/oo/classes/{name}/source/main` | +| 解锁 | `POST` | `/sap/bc/adt/oo/classes/{name}` | + +### CTS 传输管理端点 + +| 操作 | 方法 | 端点 | +|------|------|------| +| 查询传输请求 | `GET` | `/sap/bc/adt/cts/transportrequests` | +| 创建传输请求 | `POST` | `/sap/bc/adt/cts/transportrequests` | +| 发布传输请求 | `POST` | `/sap/bc/adt/cts/transportrequests/{number}/newreleasejobs` | + +## 7. ABAP 开发对象激活步骤 + +> [!important] 标准激活流程 +> 1. **修改本地源代码** — 在 `.abap` 文件中编辑 +> 2. **同步到 SAP** — `python test_adt_client.py push` +> - 脚本自动执行:锁定 → 写入 → 解锁 → 激活 +> 3. **查看日志确认** — 检查 `adt_client.log` 确认无激活错误 + +## 🔗 相关笔记 + +- [[sap-cli/03.修改代码-原理|03.修改代码 - 原理]] — 锁定/编辑/激活原理 +- [[sap-cli/04.检查代码-原理|04.检查代码 - 原理]] — 语法检查与 ATC +- [[sap-cli/05.提交与传输-原理|05.提交与传输 - 原理]] — CTS 传输管理 +- [[sap-cli/06.ADT测试代码|06.ADT 测试代码]] — 初始测试脚本 + +## 📚 参考来源 + +- [abap-adt-py (GitHub)](https://github.com/timkoehne/abap-adt-py) — Python ADT 客户端库 +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide) +- [End-to-End SAP Automation with ADT REST Services](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) diff --git a/docs/adt/README.md b/docs/adt/README.md new file mode 100644 index 0000000..cc9b162 --- /dev/null +++ b/docs/adt/README.md @@ -0,0 +1,92 @@ +--- +title: SAP ADT 学习笔记总览 +created: 2026-05-18 +tags: + - SAP + - ADT + - ABAP + - learning-notes +--- + +# SAP ADT (ABAP Development Tools) 学习笔记 + +> [!info] 关于本系列 +> 本系列笔记深入探讨 SAP ADT 的技术原理,覆盖从底层架构到各项开发功能的实现机制。 +> 参考来源:[SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide?locale=en-US) + +## 📌 什么是 SAP ADT? + +**ABAP Development Tools (ADT)** 是 SAP 提供的现代化 ABAP 开发工具集,基于 Eclipse 平台构建,使开发者能够在 Eclipse IDE 中进行 ABAP 开发,替代传统的 SAP GUI + SE80 开发模式。 + +核心特点: +- **客户端/服务器架构** — IDE 作为客户端,SAP 后端作为服务器 +- **REST API 驱动** — 所有操作通过 `/sap/bc/adt/*` REST 端点与后端通信 +- **多 IDE 支持** — Eclipse(当前) → VS Code(2026年) → 更多 IDE +- **平台无关** — 基于 HTTP(S) 协议,任何平台均可访问 + +## 📂 笔记结构 + +### 架构与原理 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/01.ADT架构与通信原理\|01.ADT架构与通信原理]] | ADT 的三层架构、REST 通信层、服务发现机制 | + +### 核心功能原理 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/02.查询代码-原理\|02.查询代码 - 原理]] | 仓库对象搜索、源代码全文搜索、Where-Used 导航 | +| [[sap-cli/03.修改代码-原理\|03.修改代码 - 原理]] | 锁定-编辑-解锁-激活工作流、源代码读写 | +| [[sap-cli/04.检查代码-原理\|04.检查代码 - 原理]] | 语法检查、ATC 代码质量检查、静态分析 | +| [[sap-cli/05.提交与传输-原理\|05.提交与传输 - 原理]] | 传输请求管理、传输发布、版本管理 | + +### 实践 +| 笔记 | 说明 | +|------|------| +| [[sap-cli/06.ADT测试代码\|06.ADT测试代码]] | Python 测试脚本:连接验证、服务发现、读取程序源代码 | +| [[sap-cli/07.ADT代码同步工具\|07.ADT代码同步工具]] | 完整使用指南:拉取代码、传输请求、推送代码、激活代码 | + +## 测试服务器 +ADT url=http://support.learningleader.com.cn:55955/sap/bc/adt?sap-client=100 +user=admin2 +password=654321 +PROGRAM=ZIDTR_IMPORT_ACCOUNTING_ORDER + +## 🔗 关键外部资源 + +- [SAP Help Portal - ADT User Guide](https://help.sap.com/docs/abap-cloud/abap-development-tools-user-guide/about-abap-development-tools-user-guide?locale=en-US) +- [Behind the Design: How We Transformed the ABAP Development Tools](https://community.sap.com/t5/technology-blog-posts-by-sap/behind-the-design-how-we-transformed-the-abap-development-tools/ba-p/14258121) +- [End-to-End SAP Automation with ADT REST Services and AI](https://medium.com/@onuryz.itu/end-to-end-sap-automation-with-adt-rest-services-and-ai-a-modern-alternative-to-gui-scripting-343ea86064ea) +- [Creating an ABAP in Eclipse Plug-in Using the ADT SDK](https://community.sap.com/t5/application-development-and-automation-blog-posts/creating-a-abap-in-eclipse-plug-in-using-the-adt-sdk-part-2/ba-p/13093582) +- [abap-adt-api (GitHub)](https://github.com/marcellourbani/abap-adt-api) — 开源 ADT REST 客户端 +- [erpl-adt (GitHub)](https://github.com/DataZooDE/erpl-adt) — ADT REST CLI 和 MCP 服务器 + +## 🗺️ 技术栈一览 + +``` +┌─────────────────────────────────────────────────────┐ +│ 开发者 IDE 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Eclipse │ │ VS Code │ │ 自定义 HTTP 客户端│ │ +│ │ (ADT插件) │ │ (2026+) │ │ (Python/JS/AI) │ │ +│ └─────┬─────┘ └────┬─────┘ └───────┬──────────┘ │ +│ │ │ │ │ +│ └──────────────┼────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ REST over HTTP(S) │ │ +│ │ /sap/bc/adt/* │ │ +│ └─────────┬───────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ SAP ABAP 后端服务器 │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │ │ +│ │ │ICF Handler│ │ADT REST │ │ ABAP Runtime │ │ │ +│ │ │ (SICF) │ │Framework│ │ (Syntax/ATC) │ │ │ +│ │ └─────────┘ └─────────┘ └───────────────┘ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │ │ +│ │ │Repository│ │Transport│ │ Activation │ │ │ +│ │ │ (搜索) │ │ (CTS) │ │ Framework │ │ │ +│ │ └─────────┘ └─────────┘ └───────────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` diff --git a/docs/assets/architecture.drawio b/docs/assets/architecture.drawio new file mode 100644 index 0000000..cf3d0a6 --- /dev/null +++ b/docs/assets/architecture.drawio @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/architecture.drawio.png b/docs/assets/architecture.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..c3a51fb06d423465f148a5660fd6082f4a3da9b1 GIT binary patch literal 524300 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 KmcW!~n0 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/dev/AGENTS.md b/docs/dev/AGENTS.md new file mode 100644 index 0000000..4d901c6 --- /dev/null +++ b/docs/dev/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md — AI 代理协作指南 + +## 项目概述 + +sap-cli 是 Python CLI 工具,通过 SAP ADT REST API 管理 ABAP 开发对象。 +修改代码前请先阅读 `CLAUDE.md` 了解项目结构和开发规范。 + +## OpenSpec 规范驱动开发 + +本项目使用 OpenSpec 进行规范驱动开发。**修改任何功能前,必须先查阅对应的 spec 文件。** + +### Spec 文件位置 + +所有系统行为的 source of truth 位于 `openspec/specs/` 下: + +| 域 | 文件 | 内容 | +|----|------|------| +| 连接与认证 | `openspec/specs/connection/spec.md` | 登录、认证、会话管理 | +| 对象生命周期 | `openspec/specs/object-lifecycle/spec.md` | 16 种对象类型的 CRUD | +| 批量操作 | `openspec/specs/batch-ops/spec.md` | 初始化、批量同步、清单刷新 | +| 搜索与浏览 | `openspec/specs/search-browse/spec.md` | 对象列表、where-used、源码搜索 | +| 传输管理 | `openspec/specs/transport/spec.md` | 传输请求管理、传输对象列表 | +| 代码质量 | `openspec/specs/quality/spec.md` | ATC 检查、代码格式化、Diff | +| 配置管理 | `openspec/specs/config/spec.md` | 多系统配置、凭据存储、Profile | + +### 变更流程 + +1. **查阅 spec**: 在 `openspec/specs/` 中找到相关的 domain spec +2. **创建 change**: 在 `openspec/changes//` 下创建提案 +3. **编写 proposal.md**: 描述变更内容、影响范围 +4. **编写 design.md**: 技术设计,包含 ADT 端点详情 +5. **编写 tasks.md**: 任务列表,每个任务独立可测试 +6. **实现**: 按 tasks.md 逐步编写代码 +7. **更新 spec**: 完成后将变更合并回 specs/ + +### Spec 编写规则 + +- 使用 Given/When/Then 格式编写 Scenario +- 每个 Requirement MUST 指定 ADT API 端点 +- 使用 RFC 2119 关键词: SHALL(必须)、MUST(强制)、SHOULD(推荐) +- 中文编写说明文字,英文编写 Requirement 名称和 Scenario 关键词 + +## 代码修改指南 + +### 修改前检查清单 + +- [ ] 阅读了相关的 spec 文件 +- [ ] 理解了现有的代码结构 +- [ ] 已创建 change proposal(如果涉及功能变更) +- [ ] 了解受影响的 ADT 端点 + +### 代码风格 + +- PEP 8 编码规范 +- 使用 type hints(`from __future__ import annotations`) +- 使用 f-strings 格式化字符串 +- 函数和类使用 docstring +- logging 使用 `logging.getLogger("sapcli.module")` + +### 新增对象类型 + +如需添加新的 ABAP 对象类型支持: + +1. 在 `sapcli/types.py` 中定义 `ObjectTypeConfig` 并 `_register()` +2. 更新 `openspec/specs/core/spec.md` 的类型注册表 +3. 如有特殊的 URI 模板或创建逻辑,在 `sapcli/client.py` 中处理 +4. 在 `sapcli/scanner.py` 的 `DIRECTORY_TYPE_MAP` 中添加目录映射 +5. 编写测试用例 + +### 新增 CLI 命令 + +1. 在 `sapcli/cli/parser.py` 的 `build_parser()` 中添加 subparser +2. 在 `sapcli/commands/` 下实现 `cmd_xxx()` 函数(或添加到现有模块) +3. 在 `sapcli/commands/__init__.py` 中导出 +4. 在 `sapcli/cli/app.py` 的 `command_map` 中注册 +5. 更新相关 spec 文件 +6. 编写测试用例 + +### 错误处理 + +- 所有自定义异常继承自 `SapCliError` +- 新增异常类型在 `sapcli/exceptions.py` 中定义 +- CLI 主循环捕获 `SapCliError`,打印友好信息后 exit(1) + +## 测试 + +```bash +python -m pytest tests/test_sapcli.py -v +``` + +## 注意事项 + +- SAP ADT REST API 使用 HTTP Basic Auth + CSRF Token +- 大部分 ADT 端点需要 stateful 会话(锁定/写入时) +- DDIC 对象通过 XML body 创建(非纯文本) +- function 类型名称格式: `组名/模块名` +- tabletype 使用 VIT 端点,需要大写名称 diff --git a/docs/dev/CLAUDE.md b/docs/dev/CLAUDE.md new file mode 100644 index 0000000..7930615 --- /dev/null +++ b/docs/dev/CLAUDE.md @@ -0,0 +1,180 @@ +# sap-cli — AI 协作指南 + +> [!danger] 核心约束:文档优先,禁止擅自读取源代码 +> 本项目提供了完整的技术文档体系,AI 在协作时**必须优先阅读文档**获取所需信息,**严禁未经许可直接读取 Python 源代码文件**(`sapcli/` 包内的 `.py` 文件、`main.py`、`test/` 目录)。 +> +> 如果发现文档描述与实际行为不一致,**必须先向用户说明差异并征得同意**,才能读取源代码进行校验。 + +## 项目简介 + +sap-cli 是一个命令行工具,通过 SAP ADT REST API 实现 ABAP 开发对象的远程管理(创建、同步、下载、查询、删除),让开发者可以在本地编辑器中编写 ABAP 代码,一行命令同步到 SAP 系统。 + +## 文档体系(AI 必读) + +> [!important] 文档覆盖了项目的全部设计细节,AI 应按需查阅以下文档,而非阅读源代码。 + +| 文档 | 用途 | 文件路径 | +| 文档 | 说明 | 路径 | +|------|------|------| +| 使用指南 | 命令详解、参数说明、输出示例 | `docs/guide.md` | +| 技术说明 | 架构、模块职责、通信流程、异常体系 | `docs/dev/architecture.md` | +| 批量同步设计 | manifest 清单、拓扑排序、批量 sync | `docs/dev/batch-design.md` | +| 测试报告 | 测试场景覆盖 | `docs/dev/test-report.md` | +| ADT 学习笔记总览 | ADT 原理系列索引 | `docs/adt/README.md` | + +### 按需求查阅 + +| 需求 | 文档 | +|------|------| +| 了解某个命令怎么用 | `docs/guide.md` | +| 了解模块接口 / API 调用链 | `docs/dev/architecture.md` | +| 了解批量同步 / manifest | `docs/dev/batch-design.md` | +| 了解 ADT REST API 原理 | `docs/adt/` 目录下的系列笔记 | +| 了解支持哪些对象类型 | `README.md` 功能矩阵 | + +## AI 行为规范 + +### 1. 文档优先原则 + +- **所有问题先查文档** — 项目文档完整覆盖了架构、命令、API、模块接口、异常体系等全部信息 +- **文档即真相** — 以文档描述为准进行操作,不需要通过读代码"验证" +- **引用文档回答** — 回答用户问题时引用具体文档路径,方便用户追溯 + +### 2. 源代码访问限制 + +> [!warning] 以下文件属于源代码,未经用户许可**禁止读取**: +> - `main.py` — CLI 入口 +> - `sapcli/*.py` — 核心包(config / types / client / commands / exceptions / manifest / scanner / sorter) +> - `test/**/*.py` — 测试套件 + +**允许读取的文件:** +- `*.md` — 所有文档文件 +- `*.abap` — ABAP 源代码文件(这是用户要同步到 SAP 的业务代码) +- `config.ini` — 连接配置(注意不要泄露密码) +- `manifest.json` — 项目清单文件 +- `*.drawio` / `*.png` — 架构图等资源文件 + +### 3. 源代码校验流程 + +当文档描述与实际执行效果不一致时: + +``` +1. 向用户报告:"文档描述 XXX,但实际表现为 YYY,差异点为 ZZZ" +2. 说明需要读取哪些源文件、读取目的是什么 +3. 等待用户明确同意后,方可读取源代码 +4. 读取后只关注差异点,不要全量阅读无关代码 +``` + +### 4. 典型操作流程 + +#### 帮助用户同步代码到 SAP + +``` +1. 确认用户的 ABAP 文件路径和对象信息(名称、类型) +2. 执行: python main.py sync --name <名称> --type <类型> --path <文件路径> +3. 如果成功 → 告知用户 +4. 如果语法检查失败 → 展示错误信息,提示用户修改后重试 +5. 如果激活失败 → 展示错误信息,提示用户修改后重试 +``` + +#### 帮助用户创建新对象 + +``` +1. 确认对象名称、类型、描述 +2. 执行: python main.py create --name <名称> --type <类型> --description "<描述>" +3. 创建成功后提示用户下载模板或直接编辑 +``` + +#### 帮助用户批量同步 + +``` +1. 确认项目根目录路径 +2. 如果没有 manifest.json,先执行: python main.py init --path <项目目录> +3. 预览执行计划: python main.py sync --all --path <项目目录> --dry-run +4. 确认后执行(推荐指定 --corr_nr 避免交互中断): + python main.py sync --all --path <项目目录> --corr_nr <传输请求号> +5. 如果不指定 --corr_nr,会在启动时弹出一次传输请求选择,后续自动复用 +``` + +## 命令速查 + +```bash +# 单对象操作 +python main.py create --name <名称> --type <类型> [--description <描述>] [--corr_nr <请求号>] +python main.py info --name <名称> --type <类型> +python main.py download --name <名称> --type <类型> --path <保存目录> +python main.py sync --name <名称> --type <类型> --path <.abap文件> [--corr_nr <请求号>] +python main.py delete --name <名称> --type <类型> + +# 批量操作(基于 manifest 清单) +python main.py init --path <项目目录> +python main.py sync --all --path <项目目录> [--dry-run] [--fail-fast] +python main.py refresh --path <项目目录> + +# 搜索与浏览 +python main.py list --type <类型> [--package <包名>] [--prefix <前缀>] +python main.py whereused --name <名称> --type <类型> +python main.py search --query <关键词> [--type <类型>] + +# 传输管理 +python main.py transport list +python main.py transport info --corr_nr <请求号> +python main.py transport release --corr_nr <请求号> +python main.py transport objects --corr_nr <请求号> + +# 代码质量 +python main.py check --name <名称> --type <类型> +python main.py format --name <名称> --type <类型> +python main.py diff --name <名称> --type <类型> [--path <本地文件>] + +# 包管理 +python main.py package create --name <包名> [--description <描述>] +python main.py package info --name <包名> +python main.py package list --name <包名> + +# CDS View +python main.py cds download --name --path <目录> +python main.py cds sync --name --path +python main.py cds create --name [--description <描述>] + +# 辅助工具 +python main.py analyze --path <项目目录> +python main.py scaffold --name <名称> --template <模板> [--package <包名>] +python main.py config show +python main.py config list-profiles +``` + +## 对象类型速查 + +| 类型参数 | 说明 | 名称格式 | +|---------|------|---------| +| `report` | 程序/报表 | 直接使用程序名 | +| `class` | ABAP 类 | 直接使用类名 | +| `interface` | ABAP 接口 | 直接使用接口名 | +| `function` | 函数模块 | `函数组名/函数模块名` | +| `functiongroup` | 函数组 | 直接使用函数组名 | +| `include` | Include 程序 | 直接使用程序名 | +| `domain` | 域 | 直接使用域名 | +| `dataelement` | 数据元素 | 直接使用元素名 | +| `table` | 透明表 | 直接使用表名 | +| `structure` | 结构 | 直接使用结构名 | +| `tabletype` | 表类型 | 直接使用类型名 | +| `cdsview` | CDS View | 直接使用 CDS 名 | +| `messageclass` | 消息类 | 直接使用消息类名 | +| `view` | 数据库视图 | 直接使用视图名 | +| `searchhelp` | 搜索帮助 | 直接使用搜索帮助名 | +| `lockobject` | 锁对象 | 直接使用锁对象名 | + +## 依赖排序优先级(批量同步) + +``` +domain(10) → dataelement(20) → structure(25) → table(30) → tabletype(40) → view(42) → lockobject(44) → searchhelp(46) → messageclass(48) → interface(50) → class(60) → function(70) → functiongroup(75) → include(78) → cdsview(79) → report(80) +``` + +## 关键技术要点 + +- **认证**: HTTP Basic Auth + CSRF Token +- **sync 流程**: 检查/创建 → 锁定 → 写入 → 解锁 → 语法检查 → 激活 +- **锁定机制**: stateful session,自动检测传输请求绑定 +- **配置加载优先级**: 环境变量 > --config 指定文件 > 工作目录 config.ini > 脚本目录 config.ini +- **唯一第三方依赖**: `requests` diff --git a/docs/dev/adt-endpoint-reference.md b/docs/dev/adt-endpoint-reference.md new file mode 100644 index 0000000..4b695b5 --- /dev/null +++ b/docs/dev/adt-endpoint-reference.md @@ -0,0 +1,51 @@ +# ADT 对象类型码映射(来源:abap-adt-api objectcreator.ts) + +## 已确认的 ADT 可创建类型 + +| TypeId | 对象类型 | ADT 端点路径 | +|--------|---------|-------------| +| CLAS/OC | Class | /oo/classes | +| INTF/OI | Interface | /oo/interfaces | +| PROG/P | Program/Report | /programs/programs | +| PROG/I | Include | /programs/includes | +| FUGR/F | Function Module | /functions/groups/{grp}/fmodules | +| FUGR/FF | Function Group | /functions/groups | +| DOMA/DD | Domain | /ddic/domains | +| DTEL/DE | Data Element | /ddic/dataelements | +| TABL/DT | Table | /ddic/tables | +| TABL/DS | Structure | /ddic/structures | +| MSAG/N | Message Class | /oo/t100/messages/classes | +| DDLS/DF | CDS View (DDL Source) | /ddic/ddlsources | +| DCLS/DL | CDS Access Control (DCL) | /ddic/dclsources | +| DDLX/EX | Metadata Extension | /ddic/metadataextensions | +| DDLA/ADF | Abstract Entity | /ddic/ddlabstractentities (推测) | +| SRVD/SRV | Service Definition | /ddic/servicedefinitions (推测) | +| AUTH | Authorization Object | (需验证) | +| SUSO/B | Auth Check Object | (需验证) | + +## 确认不在 ADT 标准创建列表中的类型 + +| 类型 | 原因 | 替代方案 | +|------|------|---------| +| Type Group (TYPE-POOL) | ADT 可能归入 programs | 通过 /programs/typegroups 尝试 | +| Number Range Object | 无 ADT 创建端点 | 需 RFC(NUMBER_RANGE_GET_INFO) | +| Enhancement Spot | 使用专用 /enhancements 端点 | 需验证 | +| Behavior Definition (BDEF) | RAP 专用,S/4HANA Cloud 才有 | NW 7.40 可能不支持 | +| SICF Service | 使用 /discovery/services | 需验证 | +| Table Type | 通过 VIT 端点 | 已有部分支持 | + +## Phase 2 调整建议 + +### 可直接实现(ADT 有标准端点) +- ✅ DCL (CDS Access Control) — /ddic/dclsources,类似 CDS View +- ✅ DDLX (Metadata Extension) — /ddic/metadataextensions,类似 CDS View +- ✅ DDLA (Abstract Entity) — 类似 CDS View + +### 需要验证端点后决定 +- ⚠️ Type Group — 可能在 /programs/typegroups +- ⚠️ Enhancement Spot — 可能在 /enhancements +- ⚠️ SICF Service — 可能通过 /discovery/services + +### NW 7.40 不支持(需移到远期) +- ❌ Behavior Definition (BDEF) — RAP 专用,NW 7.40 无此功能 +- ❌ Number Range Object — 无 ADT 端点,需 RFC diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 0000000..60d71a9 --- /dev/null +++ b/docs/dev/architecture.md @@ -0,0 +1,379 @@ +# sap-cli — 技术说明 + +## 目录 + +- [项目定位](#项目定位) +- [架构概览](#架构概览) +- [包结构](#包结构) +- [模块职责](#模块职责) +- [通信流程](#通信流程) +- [对象类型映射](#对象类型映射) +- [异常体系](#异常体系) +- [配置加载策略](#配置加载策略) +- [日志机制](#日志机制) +- [错误处理策略](#错误处理策略) +- [测试架构](#测试架构) +- [依赖关系](#依赖关系) + +--- + +## 项目定位 + +sap-cli 是一个轻量级命令行工具,通过 SAP ADT (ABAP Development Tools) REST API 与 SAP NetWeaver 系统交互,实现 ABAP 开发对象的远程管理(创建、同步、下载、查询、删除)。 + +**核心设计原则:** + +- 零 SAP 专有客户端依赖,仅基于标准 HTTP 协议 +- 唯一第三方依赖为 `requests` +- 所有通信均通过 ADT REST API 完成,数据格式为 XML / Plain Text + +--- + +## 架构概览 + +![[sap-cli/doc/assets/architecture.drawio.png]] + +架构分为三层: + +| 层级 | 组件 | 说明 | +|------|------|------| +| **用户终端** | `main.py` | CLI 入口,argparse 参数解析与命令分发 | +| **核心包** | `sapcli/` | 5 个模块:config / types / exceptions / client / commands | +| **外部系统** | SAP NetWeaver | ADT REST API 端点(Programs / OO Objects / DDIC / CTS) | + +左侧外部文件通过虚线连接至核心包: + +- `config.ini` — 连接配置(读取) +- `.abap 文件` — 本地源代码(读写) +- `log/` — 运行日志(写入) + +核心通信链路:`client.py (ADTClient)` 通过 HTTP/REST(Basic Auth + CSRF Token)与 SAP 系统交互。 + +## 包结构 + +``` +sap-cli/ +├── main.py # CLI 入口(argparse + 命令分发) +├── config.ini # 默认连接配置文件 +├── sapcli/ # 核心功能包 +│ ├── __init__.py +│ ├── config.py # 配置加载(INI / 环境变量) +│ ├── types.py # 对象类型定义与名称解析 +│ ├── client.py # ADT REST API 客户端 +│ ├── commands.py # 5 个业务命令的实现 +│ └── exceptions.py # 自定义异常层级 +├── test/ +│ └── script/ +│ └── test_main.py # 集成测试套件(子进程调用) +├── doc/ # 文档 +│ ├── ADT/ # ADT 技术原理系列 +│ ├── 技术说明.md # 本文档 +│ ├── sap-cli使用指南.md # 用户操作指南 +│ └── 测试报告.md # 测试覆盖报告 +└── log/ # 运行时日志目录 +``` + +--- + +## 模块职责 + +### main.py — CLI 入口 + +| 组件 | 职责 | +|------|------| +| 参数定义 | 定义 5 个子命令(download / sync / delete / info / create)及其参数 | +| 配置加载 | 调用 `sapcli.config.load_config()` 获取连接参数 | +| 客户端初始化 | 创建 `ADTClient` 实例并执行登录(获取 CSRF Token) | +| 命令分发 | 根据子命令名称映射到对应的 `cmd_*()` 函数 | + +### sapcli/config.py — 配置管理 + +| 组件 | 职责 | +|------|------| +| `SAPConfig` | 连接参数数据类(host / client / user / password) | +| `load_config()` | 按优先级链加载配置:环境变量 → 指定文件 → 工作目录 → 脚本目录 | + +### sapcli/types.py — 对象类型系统 + +| 组件 | 职责 | +|------|------| +| `ObjectTypeConfig` | 单个 ABAP 对象类型的 URI 模板与元数据(数据类) | +| `OBJECT_TYPE_CONFIG` | 9 种 ABAP 对象类型的注册表 | +| `ParsedName` | 解析后的对象名组件(数据类) | +| `parse_object_name()` | 将用户输入解析为 ADT URI 所需的路径片段 | +| `get_type_config()` | 按类型键查询对象类型配置 | +| `all_type_keys()` | 返回所有受支持的对象类型列表 | + +### sapcli/client.py — ADT 通信层 + +| 方法 | 职责 | +|------|------| +| `login()` | Basic Auth 认证 + 获取 CSRF Token | +| `object_exists()` | 检查对象是否存在于 SAP 系统 | +| `get_source()` | 下载对象源代码 | +| `set_source()` | 上传(写入)对象源代码 | +| `lock()` / `unlock()` | 对象锁定 / 解锁(stateful session) | +| `get_transport_request()` | 获取可用的传输请求号 | +| `activate()` | 激活对象 | +| `syntax_check()` | 语法检查 | +| `create_object()` | 创建新对象(支持 9 种类型) | +| `delete_object()` | 删除对象 | +| `function_group_exists()` | 检查函数组是否存在 | +| `create_function_group()` | 创建函数组容器 | + +### sapcli/commands.py — 命令处理 + +| 函数 | 职责 | +|------|------| +| `cmd_download()` | 下载对象源码到本地 `.abap` 文件 | +| `cmd_sync()` | 同步本地源码到 SAP(含自动创建、锁定、写入、检查、激活) | +| `cmd_info()` | 查询并展示对象元数据 | +| `cmd_delete()` | 从 SAP 系统删除对象(需用户确认) | +| `cmd_create()` | 在 SAP 系统创建新对象(支持模板预填充) | + +### sapcli/exceptions.py — 异常层级 + +| 异常类 | 触发场景 | +|--------|---------| +| `SapCliError` | 所有自定义异常的基类 | +| `ConfigError` | 配置文件缺失或格式错误 | +| `LoginError` | SAP 登录失败(认证错误或网络不通) | +| `ObjectNotFoundError` | 请求的对象在 SAP 系统中不存在 | +| `ObjectAlreadyExistsError` | 创建时对象已存在 | +| `LockError` | 对象锁定冲突 | +| `ActivationError` | 对象激活失败 | +| `SyntaxCheckError` | 语法检查未通过 | +| `DeleteError` | 删除操作失败 | +| `CreateError` | 创建操作失败 | +| `InvalidNameError` | 对象名称格式不合法 | + +--- + +## 通信流程 + +### 认证机制 + +采用 HTTP Basic Auth + CSRF Token 双重验证: + +``` +┌────────┐ ┌────────┐ +│ Client │ │ SAP │ +└───┬────┘ └───┬────┘ + │ GET /sap/bc/adt/compatibility/graph │ + │ Header: x-csrf-token: fetch │ + │ Header: Authorization: Basic *** │ + │ ──────────────────────────────────────────► │ + │ │ + │ 200 OK │ + │ Header: x-csrf-token: │ + │ ◄────────────────────────────────────────── │ + │ │ + │ 后续所有请求携带 CSRF Token │ + │ (POST / PUT / DELETE 必须携带) │ + │ ──────────────────────────────────────────► │ +``` + +### sync 命令 — 完整 API 调用链 + +`sync` 是最核心的命令,执行以下完整流程: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Step 1: 登录获取 CSRF Token │ +│ GET /sap/bc/adt/compatibility/graph │ +├─────────────────────────────────────────────────────────────┤ +│ Step 2: 检查对象存在性(不存在则自动创建) │ +│ GET /sap/bc/adt/programs/programs/{name} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 3: 获取传输请求号 │ +│ GET /sap/bc/adt/cts/transportrequests │ +├─────────────────────────────────────────────────────────────┤ +│ Step 4: 锁定对象(stateful session) │ +│ POST /sap/bc/adt/programs/programs/{name} │ +│ ?_action=LOCK&accessMode=MODIFY │ +├─────────────────────────────────────────────────────────────┤ +│ Step 5: 写入源代码 │ +│ PUT /sap/bc/adt/programs/programs/{name}/source/main │ +│ ?lockHandle={handle} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 6: 解锁对象 │ +│ POST /sap/bc/adt/programs/programs/{name} │ +│ ?_action=UNLOCK&lockHandle={handle} │ +├─────────────────────────────────────────────────────────────┤ +│ Step 7: 语法检查(可选) │ +│ POST /sap/bc/adt/activation?method=check │ +├─────────────────────────────────────────────────────────────┤ +│ Step 8: 激活对象 │ +│ POST /sap/bc/adt/activation?method=activate │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 锁定与传输请求 — 容错机制 + +工具内置了两层自动重试策略: + +1. **锁定重试** — 若对象已在其他传输请求中被锁定,从错误响应中提取已有的传输请求号,使用该传输号重新锁定 +2. **写入重试** — 若写入源码时因传输号不匹配而失败,自动使用正确的传输号重试 + +### Session 管理 + +| 操作类型 | Session 模式 | 说明 | +|---------|-------------|------| +| 锁定 / 写入 / 解锁 | **stateful** | 通过 `X-sap-adt-sessiontype: stateful` 头维持会话状态 | +| 读取 / 激活 | **stateless** | 无状态请求,独立响应 | +| 解锁后 | 自动切换回 **stateless** | 确保会话资源释放 | + +--- + +## 对象类型映射 + +每种 ABAP 对象类型对应不同的 ADT URI 模板: + +| 类型 | 对象 URI | 源码 URI | 备注 | +|------|---------|---------|------| +| `report` | `/sap/bc/adt/programs/programs/{name}` | `.../source/main` | ABAP 报表程序 | +| `class` | `/sap/bc/adt/oo/classes/{name}` | `.../source/main` | ABAP 类 | +| `interface` | `/sap/bc/adt/oo/interfaces/{name}` | `.../source/main` | ABAP 接口 | +| `function` | `/sap/bc/adt/functions/groups/{group}/fmodules/{name}` | `.../source/main` | 需 `函数组/函数名` 格式 | +| `functiongroup` | `/sap/bc/adt/functions/groups/{name}` | 无源码 | 仅容器,不含源码 | +| `domain` | `/sap/bc/adt/ddic/domains/{name}` | `.../source/main` | ABAP 域 | +| `dataelement` | `/sap/bc/adt/ddic/dataelements/{name}` | `.../source/main` | ABAP 数据元素 | +| `table` | `/sap/bc/adt/ddic/tables/{name}` | `.../source/main` | ABAP 透明表 | +| `tabletype` | `/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}` | 无源码 (VIT 端点) | ABAP 表类型,仅支持元数据查询 | + +> [!note] function 类型的特殊性 +> `function` 类型需要 `函数组名/函数名` 格式输入(如 `ZGROUP/Z_MY_FUNC`),URI 中包含两层路径参数(`{group}` 和 `{name}`)。 + +--- + +## 配置加载策略 + +配置按以下优先级链加载,高优先级源覆盖低优先级源: + +``` + ┌──────────────────────────────┐ + │ 环境变量 (SAP_HOST 等) │ ← 最高优先级 + └──────────────┬───────────────┘ + │ 覆盖 + ┌──────────────▼───────────────┐ + │ --config 指定的 INI 文件 │ + └──────────────┬───────────────┘ + │ 回退 + ┌──────────────▼───────────────┐ + │ 当前工作目录 / config.ini │ + └──────────────┬───────────────┘ + │ 回退 + ┌──────────────▼───────────────┐ + │ 脚本同目录 / config.ini │ ← 最低优先级 + └──────────────────────────────┘ +``` + +支持的环境变量映射: + +| INI 字段 | 环境变量 | +|---------|---------| +| `host` | `SAP_HOST` | +| `client` | `SAP_CLIENT` | +| `user` | `SAP_USER` | +| `password` | `SAP_PASSWORD` | + +--- + +## 日志机制 + +| 属性 | 值 | +|------|-----| +| 日志路径 | `log/adt_tools.log` | +| 日志级别 | `DEBUG`(记录所有 HTTP 请求/响应详情) | +| 日志格式 | `时间 │ 级别 │ 消息` | +| 写入模式 | 追加写入(append) | + +日志记录每次 API 调用的完整上下文:请求 URL、请求参数、响应状态码、响应体摘要,便于生产环境问题排查。 + +--- + +## 错误处理策略 + +### 按场景分类 + +| 场景 | 处理方式 | 对应异常 | +|------|---------|---------| +| 登录失败 | 终止并报错 | `LoginError` | +| 对象不存在(download / info / delete) | 提示不存在并退出 | `ObjectNotFoundError` | +| 对象不存在(sync) | 自动创建空对象后继续 | — | +| 对象已存在(create) | 提示已存在并退出 | `ObjectAlreadyExistsError` | +| 语法检查失败 | 显示错误详情,不执行激活 | `SyntaxCheckError` | +| 激活失败 | 显示错误/警告详情并退出 | `ActivationError` | +| 锁定冲突 | 自动提取已有传输号重试 | `LockError` | +| 写入冲突 | 自动使用正确传输号重试 | — | +| 删除操作 | 需用户输入 `yes` 确认 | — | + +### 错误处理设计原则 + +1. **快速失败** — 非预期错误立即终止,避免静默失败 +2. **自动恢复** — 锁定冲突和写入冲突具备自动重试能力 +3. **信息透明** — 所有错误均输出详细信息,日志记录完整上下文 + +--- + +## 测试架构 + +测试套件位于 `test/script/test_main.py`,通过子进程调用 `main.py`,确保端到端验证。 + +### 测试设计 + +- **独立性** — 每个测试场景独立运行,使用时间戳生成唯一临时对象名 +- **完整性** — 每个场景覆盖完整生命周期(创建 → 验证 → 修改 → 验证 → 删除 → 验证) +- **自清理** — 测试结束后自动清理临时文件(`test_output/` 目录) +- **可分组** — 支持按场景组选择运行 + +### 测试场景组 + +| 组名 | 说明 | 包含场景 | +|------|------|---------| +| `happy` | 正常流程 | Report / Class / Function+FunctionGroup / Interface 生命周期 | +| `error` | 错误处理 | 语法错误 / 对象不存在 / 重复创建 | +| `edge` | 边界条件 | sync 自动创建 / 删除确认 / DDIC 查询 | +| `existing` | 已有对象操作 | 对已有对象的下载和同步 | +| `config` | 配置验证 | 配置文件加载测试 | + +--- + +## 依赖关系 + +### 运行时依赖 + +``` +main.py +├── sapcli/ # 内部包 +│ ├── config.py +│ │ └── configparser # 解析 INI 配置文件 +│ ├── client.py +│ │ ├── requests # HTTP 通信(唯一第三方依赖) +│ │ └── xml.etree # 解析 ADT XML 响应 +│ ├── commands.py +│ │ └── logging # 日志记录 +│ ├── types.py +│ │ └── re # 对象名称解析 +│ └── exceptions.py +│ └── (无外部依赖) +├── argparse # 命令行参数解析 +└── logging # 日志配置 +``` + +### 测试时依赖 + +``` +test/script/test_main.py +├── subprocess # 子进程调用 main.py +└── 标准库(os, sys, time, shutil, re) +``` + +### 第三方依赖汇总 + +| 依赖 | 用途 | 安装方式 | +|------|------|---------| +| `requests` | HTTP 通信 | `pip install requests` | + +> [!tip] 零额外依赖 +> 除 `requests` 外,所有依赖均为 Python 标准库,无需额外安装。 diff --git a/docs/dev/batch-design.md b/docs/dev/batch-design.md new file mode 100644 index 0000000..3556980 --- /dev/null +++ b/docs/dev/batch-design.md @@ -0,0 +1,684 @@ +--- +title: 批量同步设计 — 项目清单驱动 +created: 2026-05-25 +updated: 2026-05-25 +tags: + - SAP + - ADT + - design +parent: "[[sap-cli/doc/sap-cli使用指南|sap-cli使用指南]]" +related: + - "[[sap-cli/doc/技术说明|技术说明]]" + - "[[sap-cli/doc/sap-cli使用指南#4.3 源代码同步激活 (`sync`)|sync 命令]]" +--- + +# 批量同步设计 — 项目清单驱动 + +> [!abstract] 概述 +> 为 sap-cli 工具增加**项目清单**机制,支持批量源代码同步。核心思路:在源代码目录下维护 `manifest.json` 清单文件,记录每个开发对象在 SAP 系统中的状态,后续操作基于清单驱动,避免重复查询。 + +> [!info] 设计动机 +> 现有 `sync` 命令只支持单对象操作,缺少以下能力: +> - 批量扫描本地文件并识别对象 +> - 预查询 SAP 系统状态,制定执行计划 +> - 按依赖关系排序,批量执行同步 +> - 缓存对象状态,避免每次重复查询 + +## 1. 目录约定 + +### 1.1 项目目录结构 + +项目根目录下按对象类型组织子目录,`manifest.json` 位于根目录: + +``` +project/ ← --path 指向这里(项目根目录) +├── manifest.json ← 状态清单(init 自动生成) +├── reports/ +│ └── {对象名小写}.abap +├── classes/ +│ └── {对象名小写}.abap +├── interfaces/ +│ └── {对象名小写}.abap +├── functions/ +│ └── {函数组名小写}/ +│ └── {函数模块名小写}.abap +├── domains/ +│ └── {对象名小写}.abap +├── dataelements/ +│ └── {对象名小写}.abap +├── tables/ +│ └── {对象名小写}.abap +└── tabletypes/ + └── {对象名小写}.abap +``` + +### 1.2 目录名 → 类型映射 + +`init` 扫描时按以下映射表从目录名推断对象类型: + +| 目录名 | 对象类型 | 文件 → 对象名规则 | +|--------|---------|------------------| +| `reports/` | `report` | `zmy_report.abap` → `ZMY_REPORT` | +| `classes/` | `class` | `zcl_my_class.abap` → `ZCL_MY_CLASS` | +| `interfaces/` | `interface` | `zif_my_interface.abap` → `ZIF_MY_INTERFACE` | +| `functions/` | `function` | `zgroup/z_my_func.abap` → `ZGROUP/Z_MY_FUNC` | +| `domains/` | `domain` | `z_status.abap` → `Z_STATUS` | +| `dataelements/` | `dataelement` | `z_status.abap` → `Z_STATUS` | +| `tables/` | `table` | `zmy_table.abap` → `ZMY_TABLE` | +| `tabletypes/` | `tabletype` | `zty_table.abap` → `ZTY_TABLE` | + +> [!note] 文件名 → 对象名规则 +> - 文件名去掉 `.abap` 后缀后转为大写即为对象名 +> - `functions/` 下的子目录名作为函数组名,子目录内的文件名作为函数模块名 +> - 对象名为 `函数组名大写/函数模块名大写` 格式 + +## 2. 清单文件 (`manifest.json`) + +### 2.1 结构定义 + +```json +{ + "version": 1, + "last_init": "2026-05-25T10:00:00", + "last_refresh": "2026-05-25T14:00:00", + "objects": { + "<对象名>": { + "type": "<对象类型>", + "file": "<相对文件路径>", + "system_status": "<状态>", + "corr_nr": "<传输请求号或null>", + "depends_on": ["<依赖对象名>"], + "last_sync": "", + "last_sync_result": "<结果>" + } + } +} +``` + +### 2.2 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `version` | int | 清单格式版本号,当前为 `1` | +| `last_init` | string | 最近一次 `init` 的时间戳 | +| `last_refresh` | string | 最近一次 `refresh` 的时间戳 | +| `objects` | object | 以对象名为 key 的字典 | +| `objects..type` | string | 对象类型(report/class/interface/...) | +| `objects..file` | string | 相对于项目根目录的文件路径 | +| `objects..system_status` | string | `active` / `inactive` / `not_exists` | +| `objects..corr_nr` | string\|null | 传输请求号,`null` 表示本地对象 `$TMP` | +| `objects..depends_on` | string[] | 依赖的对象名列表 | +| `objects..last_sync` | string\|null | 最近一次成功同步的时间 | +| `objects..last_sync_result` | string | `success` / `failed` / `pending` | + +### 2.3 示例 + +```json +{ + "version": 1, + "last_init": "2026-05-25T10:00:00", + "last_refresh": null, + "objects": { + "Z_STATUS": { + "type": "domain", + "file": "domains/z_status.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:30:00", + "last_sync_result": "success" + }, + "ZIF_MY_INTERFACE": { + "type": "interface", + "file": "interfaces/zif_my_interface.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:30:00", + "last_sync_result": "success" + }, + "ZCL_MY_CLASS": { + "type": "class", + "file": "classes/zcl_my_class.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": ["ZIF_MY_INTERFACE"], + "last_sync": "2026-05-25T11:00:00", + "last_sync_result": "success" + }, + "ZMY_REPORT": { + "type": "report", + "file": "reports/zmy_report.abap", + "system_status": "inactive", + "corr_nr": null, + "depends_on": ["ZCL_MY_CLASS"], + "last_sync": null, + "last_sync_result": "pending" + }, + "ZGROUP/Z_MY_FUNC": { + "type": "function", + "file": "functions/zgroup/z_my_func.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T10:00:00", + "last_sync_result": "success" + } + } +} +``` + +## 3. 命令变更一览 + +| 命令 | 变更类型 | 说明 | +|------|---------|------| +| `init` | **新增** | 扫描项目目录 → 查询 SAP → 生成 `manifest.json` | +| `create` | **变更** | 新增 `--path` 参数;创建成功后自动写入清单 | +| `sync` | **变更** | 新增 `--all` 批量模式;单对象模式操作后更新清单 | +| `delete` | **变更** | 新增 `--path` 参数;删除成功后从清单移除 | +| `refresh` | **新增** | 重新查询清单中所有对象的 SAP 状态 | + +## 4. 命令详细设计 + +### 4.1 `init` — 项目初始化(新增) + +#### 命令格式 + +```bash +python main.py init --path <项目根目录> +``` + +#### 执行流程 + +``` +扫描项目目录 + ├─ 按目录映射表识别文件 → 生成 {对象名, 类型, 文件路径} 列表 + ├─ 输出: "扫描到 N 个本地文件" + └─ 忽略非 .abap 文件和隐藏文件 + +逐个查询 SAP 系统 + ├─ 对象存在 → 查询 info → 获取 system_status + corr_nr + ├─ 对象不存在 → 标记 system_status: "not_exists" + └─ 查询失败 → 标记 system_status: "unknown" + +应用类型默认优先级生成初始 depends_on + (init 时只设置类型层面的默认依赖,用户可后续手动编辑) + domain/dataelement/table → 无依赖 + tabletype → 依赖同项目中的 table + interface → 无依赖 + class → 依赖同项目中被引用的 interface + function → 无显式依赖 + report → 无显式依赖 + +写入 manifest.json +输出汇总报告 +``` + +#### 输出示例 + +``` +============================================================ + sap-cli 项目初始化 +============================================================ + 项目路径: ./project + 扫描到 5 个本地文件 + + → 正在查询 SAP 系统状态... + ✓ Z_STATUS (domain) active corr: DEVK901362 + ✓ ZIF_MY_INTERFACE (interface) active corr: DEVK901362 + ✓ ZCL_MY_CLASS (class) active corr: DEVK901362 + ⚠ ZMY_REPORT (report) inactive corr: 本地对象 + ✗ ZCL_NEW_CLASS (class) 不存在 + + ✓ 清单已生成: ./project/manifest.json + 5 个对象: 3 已激活, 1 未激活, 1 不存在 +``` + +> [!tip] 清单可手动编辑 +> `manifest.json` 生成后,用户可以手动编辑 `depends_on` 字段来补充对象间的显式依赖关系。 +> `init` 只设置类型层面的默认依赖,具体的引用关系(如某个 class 实现了哪个 interface)需要用户自行标注。 + +--- + +### 4.2 `sync --all` — 批量同步(新增) + +#### 命令格式 + +```bash +python main.py sync --all --path <项目根目录> [--corr_nr <传输请求号>] [--fail-fast] [--dry-run] +``` + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--all` | 是 | 启用批量模式 | +| `--path` | 是 | 项目根目录(包含 `manifest.json`) | +| `--corr_nr` | 否 | 统一传输请求号(跳过交互选择,所有对象共用) | +| `--fail-fast` | 否 | 遇到失败立即停止(默认:跳过并继续) | +| `--dry-run` | 否 | 仅输出执行计划,不实际操作 | + +#### 执行流程 + +``` +读取 manifest.json + ├─ 不存在 → 报错: "请先执行 init" + └─ 存在 → 继续 + +扫描本地文件 vs 清单差异 + ├─ 本地有但清单没有的文件 → 提示: "N 个未纳入清单的文件,建议先 create" + └─ 清单有但本地被删的文件 → 警告,跳过 + +过滤待处理对象 + ├─ system_status == "active" && last_sync_result == "success" → 跳过(已同步) + ├─ system_status == "not_exists" → 标记为 [create + sync] + └─ 其他 → 标记为 [sync] + +拓扑排序 + ├─ 按 depends_on 建有向图 + ├─ 无显式依赖的按类型默认优先级排序 + └─ 检测循环依赖 → 报错退出 + +--dry-run 模式: + 输出执行计划,不实际操作 + +传输请求预选择(一次性交互): + ├─ 扫描待处理对象,统计需要传输请求号的数量 + ├─ 指定了 --corr_nr → 直接使用,无交互 + ├─ 全部对象已绑定请求号或为本地对象 → 无交互 + └─ 有对象需要请求号 → 弹出一次选择,选定后全局复用: + ├─ 选择已有请求号 → 后续所有需要请求号的对象都使用此请求号 + ├─ 新建请求号 → 同上 + └─ 不使用请求号 → 所需对象都作为本地对象处理 + +执行阶段(按排序顺序逐个处理,使用预选的传输请求号): + 对每个对象: + ├─ 本地文件不存在 → 跳过,警告 + ├─ system_status == "not_exists" → 先执行 create(使用预选请求号) + ├─ 执行 sync 逻辑(使用预选请求号,跳过逐对象交互): + │ ├─ 对象已绑定请求号 → 使用对象自身绑定的请求号 + │ ├─ 本地对象($TMP)→ 无需请求号 + │ └─ 未绑定 → 使用预选的统一请求号(不再弹出交互) + ├─ 成功 → 更新 manifest: + │ system_status: "active" + │ corr_nr: 实际使用的请求号 + │ last_sync: 当前时间 + │ last_sync_result: "success" + └─ 失败 → + ├─ --fail-fast → 停止,输出已处理和未处理的对象 + └─ 默认 → 标记 last_sync_result: "failed",跳过,继续下一个 + 后续依赖此对象的对象也自动跳过(标记 skipped) + +输出汇总报告 +``` + +#### dry-run 输出示例 + +``` +============================================================ + sap-cli 批量同步 (dry-run) +============================================================ + 项目路径: ./project + 清单对象: 5 + + 执行计划(按依赖排序): + 1. Z_STATUS (domain) → sync [inactive → active] + 2. ZIF_MY_INTERFACE (interface) → 跳过 [已是最新] + 3. ZCL_MY_CLASS (class) → sync [依赖: ZIF_MY_INTERFACE ✓] + 4. ZMY_REPORT (report) → sync [依赖: ZCL_MY_CLASS] + 5. ZGROUP/Z_MY_FUNC (function) → 跳过 [已是最新] + + 待处理: 3 个 | 跳过: 2 个 +``` + +#### 执行输出示例 + +``` +============================================================ + sap-cli 批量同步 +============================================================ + 项目路径: ./project + 待处理: 3 个对象 + + → 预检传输请求需求... + 2 个对象已绑定请求号: Z_STATUS(DEVK901362), ZCL_MY_CLASS(DEVK901362) + 1 个对象需要传输请求: ZMY_REPORT + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 统一传输请求: DEVK901362(后续所有对象共用此请求号) + + ── [1/3] Z_STATUS (domain) ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✓ 语法检查通过 + ✓ 激活成功 + + ── [2/3] ZCL_MY_CLASS (class) ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✓ 语法检查通过 + ✓ 激活成功 + + ── [3/3] ZMY_REPORT (report) ── + ✓ 锁定成功(使用统一传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + ✗ 语法检查未通过! 1 个错误 + [错误] 行 10: Field "XXX" is unknown + ⊘ 跳过(标记为 failed) + + ============================================================ + 批量同步完成 + ============================================================ + ✓ 成功: Z_STATUS, ZCL_MY_CLASS + ✗ 失败: ZMY_REPORT (语法错误,行 10) + ⊘ 跳过: (无) + 清单已更新: ./project/manifest.json +``` + +> [!tip] 指定 `--corr_nr` 跳过交互 +> 如果已知传输请求号,使用 `--corr_nr` 参数可以直接跳过交互选择: +> ```bash +> python main.py sync --all --path ./project --corr_nr DEVK901362 +> ``` +> 此时所有未绑定请求号的对象都会使用指定的请求号,**全程零交互**。 + +--- + +### 4.3 `refresh` — 刷新清单状态(新增) + +#### 命令格式 + +```bash +python main.py refresh --path <项目根目录> +``` + +#### 执行流程 + +``` +读取 manifest.json +逐个重新查询 SAP info +更新每个对象的 system_status 和 corr_nr +(不修改 last_sync 和 last_sync_result) +输出变更摘要 +``` + +#### 使用场景 + +- 其他人修改了 SAP 端的对象 +- 传输请求状态发生变化(释放、导入等) +- 需要确认当前系统状态但不想执行同步 + +#### 输出示例 + +``` +============================================================ + sap-cli 清单刷新 +============================================================ + 项目路径: ./project + 清单对象: 5 + + → 正在查询 SAP 系统状态... + ✓ Z_STATUS (domain) active corr: DEVK901362 (未变化) + ✓ ZIF_MY_INTERFACE (interface) active corr: DEVK901362 (未变化) + ~ ZCL_MY_CLASS (class) inactive corr: DEVK901362 (状态变更: active → inactive) + ✓ ZMY_REPORT (report) inactive corr: null (未变化) + ✓ ZGROUP/Z_MY_FUNC (function) active corr: DEVK901362 (未变化) + + ✓ 刷新完成 + 变更: 1 个 | 未变化: 4 个 +``` + +--- + +### 4.4 `create` — 变更:自动写入清单 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录 +- 创建成功后:自动在 `manifest.json` 中新增一条记录 + +#### 新增的清单记录 + +```json +{ + "ZCL_NEW_CLASS": { + "type": "class", + "file": "classes/zcl_new_class.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T14:00:00", + "last_sync_result": "success" + } +} +``` + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:仅创建对象,不写清单 +- 现有不含 `--path` 的用法不受影响 + +--- + +### 4.5 `delete` — 变更:从清单移除 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录 +- 删除成功后:从 `manifest.json` 中移除该对象记录 + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:仅删除对象,不更新清单 + +--- + +### 4.6 `sync`(单对象) — 变更:操作后更新清单 + +#### 变更内容 + +- 新增 `--path` 参数:指向项目根目录(可选) +- sync 成功或失败后:更新 `manifest.json` 中对应对象的记录 + +#### 向后兼容 + +- 如果未指定 `--path`,或指定目录下没有 `manifest.json`:行为与现有完全一致 + +## 5. 依赖排序策略 + +### 5.1 类型默认优先级 + +无显式 `depends_on` 时,按类型优先级排序: + +| 优先级 | 数值 | 类型 | 说明 | +|--------|------|------|------| +| 1 | 10 | `domain` | 基础域定义 | +| 2 | 20 | `dataelement` | 依赖 domain | +| 3 | 30 | `table` | 依赖 dataelement | +| 4 | 40 | `tabletype` | 依赖 table | +| 5 | 50 | `interface` | 接口定义 | +| 6 | 60 | `class` | 可能实现 interface | +| 7 | 70 | `function` | 函数模块 | +| 8 | 80 | `report` | 程序,可能引用以上所有 | + +### 5.2 排序算法 + +采用拓扑排序(Kahn 算法): + +``` +1. 以 manifest 中所有对象为节点 +2. 建立有向边: + - 显式依赖: depends_on 中列出的对象 → 当前对象 + - 类型默认优先级: 仅当两个对象之间没有显式依赖时作为 tie-breaker +3. 执行 Kahn 拓扑排序 +4. 检测循环依赖 → 若存在未入队的节点则报错,输出循环链 +``` + +### 5.3 依赖失败传播 + +批量 `sync` 时,若对象 A 依赖对象 B,而 B 同步失败: + +``` +A.depends_on = ["B"] +B.last_sync_result = "failed" + +→ A 自动跳过,标记 last_sync_result: "skipped" +→ 依赖 A 的对象同样跳过(级联跳过) +``` + +## 6. 边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| 本地新增了 .abap 文件但清单中没有 | `sync --all` 时提示: "N 个未纳入清单的文件",建议先执行 `create` | +| 清单中有对象但本地文件被删了 | `sync --all` 时警告并跳过该对象 | +| `manifest.json` 不存在 | `sync --all` 报错,提示先执行 `init` | +| 依赖对象 sync 失败 | 跳过所有依赖它的对象(级联标记 skipped) | +| 循环依赖 | 检测到后报错,输出循环链中的对象名 | +| 非项目目录(无子目录结构) | `init` 时只扫描当前目录下的 `.abap` 文件 | +| SAP 连接中断 | 标记已处理的对象,输出中断位置,下次可从断点继续 | +| `manifest.json` 版本不匹配 | 提示版本不兼容,建议重新 `init` | +| 部分对象已绑定请求号,部分未绑定 | 已绑定的使用自身请求号,未绑定的使用预选的统一请求号 | +| 全部对象都是本地对象($TMP) | 无需传输请求,直接执行,零交互 | +| 指定了 `--corr_nr` 但部分对象已绑定其他请求号 | 已绑定的使用自身请求号(不受 `--corr_nr` 覆盖) | + +## 7. 项目生命周期 + +``` +┌─────────────────────────────────────────────────────────┐ +│ │ +│ init ────────────────────────────────────────────── │ +│ │ 扫描本地文件 → 查询 SAP → 生成 manifest.json │ +│ │ │ +│ ▼ │ +│ create(新增对象时) ──────────────────────────────── │ +│ │ 在 SAP 创建 → 自动写入 manifest │ +│ │ │ +│ ▼ │ +│ sync --all(日常同步)───────────────────────────── │ +│ │ 读 manifest → 拓扑排序 → 逐个 sync → 更新 manifest │ +│ │ │ +│ ▼ │ +│ refresh(SAP 端有变化时)──────────────────────────── │ +│ 重新查询 SAP → 更新 manifest 中的状态字段 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +> [!tip] 典型工作流 +> ```bash +> # 1. 首次使用:初始化项目清单 +> python main.py init --path ./my_project +> +> # 2. 日常开发:新增对象 +> python main.py create --name ZCL_NEW --type class --path ./my_project --description "新类" +> +> # 3. 日常开发:修改本地 .abap 文件后,批量同步 +> python main.py sync --all --path ./my_project +> +> # 4. 如需查看执行计划(不实际操作) +> python main.py sync --all --path ./my_project --dry-run +> +> # 5. 其他人修改了 SAP 端对象后,刷新清单 +> python main.py refresh --path ./my_project +> ``` + +## 8. 模块设计(实现层面) + +### 8.1 新增模块 + +| 模块 | 职责 | +|------|------| +| `sapcli/manifest.py` | 清单文件的读取、写入、查询、更新 | +| `sapcli/scanner.py` | 扫描本地目录,识别对象名和类型 | +| `sapcli/sorter.py` | 拓扑排序,依赖检测 | + +### 8.2 清单模块 (`manifest.py`) 接口 + +```python +@dataclass +class ManifestEntry: + name: str + type: str + file: str + system_status: str # "active" / "inactive" / "not_exists" + corr_nr: str | None + depends_on: list[str] + last_sync: str | None # ISO timestamp + last_sync_result: str # "success" / "failed" / "pending" + +class Manifest: + version: int + last_init: str | None + last_refresh: str | None + objects: dict[str, ManifestEntry] + + @classmethod + def load(cls, project_path: str) -> "Manifest": ... + def save(self) -> None: ... + def upsert(self, entry: ManifestEntry) -> None: ... + def remove(self, name: str) -> None: ... + def get(self, name: str) -> ManifestEntry | None: ... + def pending_objects(self) -> list[ManifestEntry]: ... +``` + +### 8.3 扫描模块 (`scanner.py`) 接口 + +```python +DIRECTORY_TYPE_MAP = { + "reports": "report", + "classes": "class", + "interfaces": "interface", + "functions": "function", + "domains": "domain", + "dataelements": "dataelement", + "tables": "table", + "tabletypes": "tabletype", +} + +@dataclass +class ScannedObject: + name: str # 大写对象名,function 类型含 / + type: str # 对象类型 + file: str # 相对于项目根目录的路径 + +def scan_project(project_path: str) -> list[ScannedObject]: ... +``` + +### 8.4 排序模块 (`sorter.py`) 接口 + +```python +TYPE_PRIORITY = { + "domain": 10, + "dataelement": 20, + "table": 30, + "tabletype": 40, + "interface": 50, + "class": 60, + "function": 70, + "report": 80, +} + +class CyclicDependencyError(SapCliError): ... + +def topological_sort( + objects: list[ManifestEntry], +) -> list[ManifestEntry]: ... +# → 按 depends_on + TYPE_PRIORITY 排序 +# → 检测循环依赖,抛出 CyclicDependencyError +``` + +## 9. 相关笔记 + +- [[sap-cli/doc/sap-cli使用指南|sap-cli 使用指南]] — 现有命令的使用说明 +- [[sap-cli/doc/技术说明|技术说明]] — 技术架构与模块设计 +- [[sap-cli/doc/ADT/03.修改代码-原理|修改代码原理]] — 锁定/编辑/激活工作流 diff --git a/docs/dev/extension-plan.md b/docs/dev/extension-plan.md new file mode 100644 index 0000000..f6147c4 --- /dev/null +++ b/docs/dev/extension-plan.md @@ -0,0 +1,530 @@ +# sap-cli 功能扩展分析报告 + +> **项目**: sap-cli — 用你喜欢的编辑器写 ABAP +> **版本**: 当前 (v1.0) +> **编制日期**: 2026-06-09 +> **定位**: 基于 SAP ADT REST API 的命令行 ABAP 开发工具 + +--- + +## 一、现有功能全景 + +### 1.1 已实现的 7 个命令 + +| 命令 | 功能 | 适用对象 | +|:-----|:-----|:---------| +| `download` | 从 SAP 下载源码到本地 .abap 文件 | report / class / interface / function / domain / dataelement / table / structure | +| `sync` | 本地代码 → SAP(锁定→写入→解锁→语法检查→激活) | 同上 | +| `create` | 在 SAP 创建开发对象(支持模板和 JSON 定义) | 10 种类型(含 functiongroup、tabletype) | +| `info` | 查询对象元数据(名称/类型/状态/负责人/修改时间等) | 10 种类型 | +| `delete` | 从 SAP 系统删除对象(需确认) | 10 种类型 | +| `init` | 扫描本地目录 → 查询 SAP → 生成 manifest.json | 批量 | +| `refresh` | 刷新清单中对象的 SAP 状态 | 批量 | + +### 1.2 已支持的对象类型 + +**ABAP 程序对象(5 种)**:report、class、interface、function、functiongroup + +**ABAP 字典对象(5 种)**:domain、dataelement、table、structure、tabletype + +### 1.3 核心能力 + +- ✅ 单对象 CRUD 全生命周期 +- ✅ 批量同步(基于 manifest.json + 拓扑排序) +- ✅ 传输请求管理(list / create / select) +- ✅ DDIC 对象通过 JSON 定义文件创建 +- ✅ 锁定-编辑-解锁-激活完整工作流 +- ✅ 语法检查集成 + +--- + +## 二、功能扩展方向 + +### 🔵 方向一:对象类型扩展 + +#### 2.1.1 ADT 已支持但工具未覆盖的对象类型 + +| 优先级 | 对象类型 | ADT 端点 | 使用场景 | 实现难度 | +|:------:|:---------|:---------|:---------|:--------:| +| ⭐⭐⭐ | **CDS View (DDL)** | `/sap/bc/adt/ddic/ddlsources` | S/4HANA 核心开发,CDS View 是现代 ABAP 开发的基础 | 中 | +| ⭐⭐⭐ | **CDS Access Control (DCL)** | `/sap/bc/adt/authorization/dclsources` | CDS 角色权限控制,与 CDS View 配套 | 中 | +| ⭐⭐⭐ | **Include 程序** | `/sap/bc/adt/programs/programs` | 大型报表拆分,复用代码片段 | 低 | +| ⭐⭐ | **消息类 (Message Class)** | `/sap/bc/adt/messageclasses` | MESSAGE 语句依赖,项目必备 | 低 | +| ⭐⭐ | **数据库视图 (View)** | `/sap/bc/adt/ddic/views` | 数据建模,查询优化 | 中 | +| ⭐⭐ | **搜索帮助 (Search Help)** | `/sap/bc/adt/ddic/searchhelps` | ALV 屏幕、F4 帮助 | 中 | +| ⭐⭐ | **锁对象 (Lock Object)** | `/sap/bc/adt/ddic/lockobjects` | 并发控制,多用户数据一致性 | 中 | +| ⭐ | **类型组 (Type Pool)** | `/sap/bc/adt/programs/programs` | 常量/类型定义集中管理 | 低 | +| ⭐ | **AMDP 类** | 复用 class 端点 | HANA 数据库过程调用 | 低 | +| ⭐ | **Web Dynpro 组件** | 专用端点 | 传统 Web UI(逐渐淘汰) | 高 | + +**实现建议**: +- 第一批优先:**Include 程序**(改动最小,复用现有 report 逻辑)+ **CDS View**(战略价值最高) +- CDS View 需要新增 `cdsview` 类型,源码是 DDL 语法(非 ABAP),激活流程与 DDIC 类似 + +#### 2.1.2 tabletype 的完整支持 + +当前 `tabletype` 使用 VIT 端点,不支持 download/sync/delete。可以探索: +- 通过 DDIC 专用端点(`/sap/bc/adt/ddic/tabletypes`)的源码读写接口 +- 或使用 VIT 的 PUT 操作实现间接源码写入 + +--- + +### 🔵 方向二:代码浏览与搜索 + +#### 2.2.1 对象列表浏览(`list` 命令) + +``` +python main.py list --type class --prefix ZCL_* --package ZFINANCE +``` + +**ADT API**: `GET /sap/bc/adt/repository/informationsystem/search` +**使用场景**: +- 浏览某个包下的所有对象 +- 按名称前缀模糊搜索 +- 按类型/所有者/修改时间筛选 + +#### 2.2.2 Where-Used 引用查询 + +``` +python main.py whereused --name ZCL_MY_CLASS --type class +``` + +**ADT API**: `GET /sap/bc/adt/whereused` +**使用场景**: +- 修改前评估影响范围 +- 自动填充 `manifest.json` 的 `depends_on` 字段(替代手动维护) +- 批量同步时自动推导依赖关系 + +#### 2.2.3 源代码全文搜索 + +``` +python main.py search --query "SELECT * FROM ZMY_TABLE" --type report +``` + +**ADT API**: `GET /sap/bc/adt/repository/informationsystem/search` (code search) +**使用场景**: 快速定位引用特定表/函数/变量的代码位置 + +--- + +### 🔵 方向三:代码质量与检查 + +#### 2.3.1 ATC 代码检查集成 + +``` +python main.py check --name ZMY_REPORT --type report --variant SAP_ABA_CHECK +``` + +**ADT API**: `POST /sap/bc/adt/qualitymanager/ats/checkruns` +**使用场景**: +- 同步前自动运行 ATC 检查(替代或补充现有语法检查) +- CI/CD 流水线中的质量门禁 +- 支持自定义检查变体 + +#### 2.3.2 代码格式化(ABAP Pretty Printer) + +``` +python main.py format --name ZMY_REPORT --type report +``` + +**ADT API**: `POST /sap/bc/adt/prettyprinter` +**使用场景**: +- 下载后自动格式化(统一缩进、大小写风格) +- sync 前自动格式化(保持 SAP 端代码风格一致) + +#### 2.3.3 代码差异对比 + +``` +python main.py diff --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +**使用场景**: +- sync 前预览即将上传的改动 +- 显示本地文件与 SAP 端源码的差异 +- 防止误覆盖他人的修改 + +--- + +### 🔵 方向四:传输管理与多系统 + +#### 2.4.1 传输请求高级管理 + +``` +python main.py transport list --status D # 查看可修改的传输请求 +python main.py transport info DEVK901362 # 查看传输请求详情 +python main.py transport release DEVK901362 # 释放传输请求 +python main.py transport objects DEVK901362 # 列出传输请求中的所有对象 +``` + +**ADT API**: +- `GET /sap/bc/adt/cts/transportrequests/{id}` — 详情 +- `POST /sap/bc/adt/cts/transportrequests/{id}?method=release` — 释放 + +**使用场景**: +- 批量同步后一键释放传输请求 +- 在命令行中管理传输请求,无需进入 SAP GUI(SE01/SE09) + +#### 2.4.2 多系统配置与跨系统同步 + +```ini +# config.ini +[DEV] +host = http://dev-sap:8000 +client = 100 + +[QAS] +host = http://qas-sap:8000 +client = 200 + +[PRD] +host = http://prd-sap:8000 +client = 300 +``` + +``` +python main.py --profile DEV download --name ZMY_REPORT --type report --path ./src +python main.py --profile QAS info --name ZMY_REPORT --type report +``` + +**使用场景**: +- 开发/测试/生产多环境切换 +- 跨系统对象对比(DEV vs QAS 代码差异) +- 一键从 DEV 下载 → 修改 → 推送到 QAS + +#### 2.4.3 传输导入监控 + +``` +python main.py transport import DEVK901362 --target QAS +python main.py transport status DEVK901362 +``` + +**使用场景**: 跟踪传输请求在目标系统的导入状态 + +--- + +### 🔵 方向五:包管理与项目结构 + +#### 2.5.1 ABAP 包(Package)操作 + +``` +python main.py package create ZFINANCE --description "财务模块" --superpackage ZBUSINESS +python main.py package list --superpackage ZBUSINESS +python main.py package move --name ZMY_REPORT --type report --package ZFINANCE +``` + +**ADT API**: `/sap/bc/adt/packages/{name}` +**使用场景**: +- 项目初始化时自动创建包结构 +- 将 $TMP 本地对象迁移到正式包中 + +#### 2.5.2 项目模板(Scaffolding) + +``` +python main.py scaffold --name ZSALES_ORDER --template "ALV Report" --package ZSALES +``` + +预置模板: +- **ALV 报表** — 包含 ALV GRID、字段目录、布局管理 +- **BAPI 封装** — 函数组 + 函数模块 + 异常处理 +- **接口类** — 接口 + 实现类 + 工厂方法 +- **数据模型** — domain + dataelement + table + tabletype + CDS View +- **增强实现** — BAdI 定义 + 实现(如果 ADT 支持) + +**使用场景**: 快速创建符合项目规范的标准代码骨架 + +#### 2.5.3 依赖自动分析 + +``` +python main.py analyze --path ./project +``` + +**功能**: +- 解析本地 .abap 文件中的 `TYPE REF TO`、`CALL METHOD`、`PERFORM` 等语句 +- 自动生成 `depends_on` 关系 +- 替代当前手动维护 manifest.json 依赖的方式 +- 结合 Where-Used API 交叉验证 + +--- + +### 🔵 方向六:Git 集成与 DevOps + +#### 2.6.1 Git Hooks 集成 + +```bash +# pre-commit hook: sync 前自动语法检查 +# post-pull hook: 自动 download 远端最新代码 +``` + +**使用场景**: +- `git commit` 前自动触发 ATC 检查 +- `git pull` 后自动拉取 SAP 端最新代码 +- `git push` 后自动触发 CI/CD + +#### 2.6.2 CI/CD Pipeline 集成 + +```yaml +# .gitlab-ci.yml 示例 +abap-sync: + stage: deploy + script: + - python main.py sync --all --path ./src --corr_nr $TR_NUMBER --fail-fast + - python main.py check --all --path ./src +``` + +**使用场景**: +- Git 提交后自动同步到 SAP 开发系统 +- ATC 检查作为质量门禁 +- 传输请求自动创建和释放 + +#### 2.6.3 代码版本对比(SAP 版本管理集成) + +``` +python main.py history --name ZMY_REPORT --type report +python main.py version --name ZMY_REPORT --type report --version 1.2 +``` + +**ADT API**: `/sap/bc/adt/programs/programs/{name}/versions` +**使用场景**: +- 查看 SAP 端的版本历史 +- 对比不同版本之间的代码差异 +- 回滚到之前的版本 + +--- + +### 🔵 方向七:交互体验优化 + +#### 2.7.1 交互式终端 UI(TUI) + +``` +python main.py tui +``` + +**功能**: +- 基于 [Textual](https://github.com/Textualize/textual) 或 [Rich](https://github.com/Textualize/rich) 的终端 UI +- 对象树状浏览器(按类型/包/状态分组) +- 源码差异对比视图 +- 批量操作进度条 + +#### 2.7.2 全局配置与 Profile + +```bash +sap-cli config set default_host http://dev-sap:8000 +sap-cli config set default_client 100 +sap-cli config set auto_format true +sap-cli config set atc_variant SAP_ABA_CHECK +sap-cli config set timeout 30 +``` + +**使用场景**: +- 替代手动编辑 config.ini +- 按项目/环境保存不同配置 +- 设置全局默认参数(超时、格式化、检查变体等) + +#### 2.7.3 Shell 自动补全 + +```bash +eval "$(sap-cli completion bash)" +eval "$(sap-cli completion zsh)" +``` + +**使用场景**: Tab 键自动补全命令、对象类型、对象名 + +--- + +### 🔵 方向八:AI 与智能化 + +#### 2.8.1 AI 辅助代码生成 + +``` +python main.py generate --type report --description "根据销售订单号查询交货明细的ALV报表" --output ./src/zdelivery_detail.abap +``` + +**使用场景**: +- 结合 LLM API(如 Claude/GPT)根据自然语言描述生成 ABAP 代码 +- 自动识别所需的表、数据元素、函数模块 +- 生成的代码自动通过语法检查 + +#### 2.8.2 MCP 服务器模式 + +``` +python main.py mcp-server --port 8080 +``` + +**参考**: [erpl-adt](https://github.com/DataZooDE/erpl-adt) 已实现 MCP 服务器 +**使用场景**: +- 作为 MCP 工具供 AI 助手(如 Claude Desktop)调用 +- AI 可以直接浏览 SAP 对象、下载/同步代码 +- 实现"用自然语言修改 SAP 系统"的终极目标 + +#### 2.8.3 智能代码审查 + +``` +python main.py review --name ZMY_REPORT --type report --rules performance,security +``` + +**使用场景**: +- 基于规则的静态分析(SQL 注入、性能反模式、命名规范) +- 结合 AI 的代码审查建议 +- 自动生成改进建议 + +--- + +### 🔵 方向九:安全与运维 + +#### 2.9.1 密码安全存储 + +``` +python main.py auth login # 交互式输入密码,存入系统 keyring +python main.py auth status # 检查登录状态 +python main.py auth logout # 清除凭证 +``` + +**实现方案**: +- 使用 [keyring](https://github.com/jaraco/keyring) 库 +- 支持 Windows Credential Manager / macOS Keychain / Linux Secret Service +- 不再明文存储密码 + +#### 2.9.2 SSL 证书验证 + +```ini +[DEV] +host = https://dev-sap:44300 +verify_ssl = true +ca_bundle = /path/to/corp-ca.pem +``` + +**使用场景**: +- 生产环境安全要求 +- 企业内网 CA 证书支持 +- 当前硬编码 `verify=False`,存在中间人攻击风险 + +#### 2.9.3 审计日志 + +``` +python main.py audit --from 2026-06-01 --to 2026-06-09 +``` + +**使用场景**: +- 记录所有 create/sync/delete 操作的审计日志 +- 支持按时间/用户/操作类型查询 +- 满足企业合规要求 + +--- + +## 三、扩展优先级矩阵 + +按照 **业务价值 × 实现难度** 排序: + +| 优先级 | 扩展方向 | 业务价值 | 实现难度 | 建议版本 | +|:------:|:---------|:--------:|:--------:|:--------:| +| 🔴 P0 | Include 程序支持 | ⭐⭐⭐ | ⭐ | v1.1 | +| 🔴 P0 | 对象列表浏览 (list) | ⭐⭐⭐ | ⭐⭐ | v1.1 | +| 🔴 P0 | 多系统配置 (profile) | ⭐⭐⭐ | ⭐⭐ | v1.2 | +| 🔴 P0 | 密码安全存储 (keyring) | ⭐⭐⭐ | ⭐ | v1.2 | +| 🟠 P1 | CDS View 支持 | ⭐⭐⭐ | ⭐⭐⭐ | v1.3 | +| 🟠 P1 | Where-Used 引用查询 | ⭐⭐⭐ | ⭐⭐ | v1.3 | +| 🟠 P1 | 代码差异对比 (diff) | ⭐⭐⭐ | ⭐⭐ | v1.3 | +| 🟠 P1 | 传输请求高级管理 | ⭐⭐⭐ | ⭐⭐ | v1.4 | +| 🟠 P1 | ABAP 包操作 | ⭐⭐ | ⭐⭐ | v1.4 | +| 🟡 P2 | ATC 代码检查集成 | ⭐⭐ | ⭐⭐⭐ | v2.0 | +| 🟡 P2 | 代码格式化 (Pretty Printer) | ⭐⭐ | ⭐⭐ | v2.0 | +| 🟡 P2 | 依赖自动分析 | ⭐⭐⭐ | ⭐⭐⭐ | v2.0 | +| 🟡 P2 | 项目模板 (Scaffolding) | ⭐⭐ | ⭐⭐ | v2.0 | +| 🟡 P2 | CI/CD Pipeline 集成 | ⭐⭐⭐ | ⭐⭐ | v2.1 | +| 🟢 P3 | 消息类 / 视图 / 搜索帮助 / 锁对象 | ⭐⭐ | ⭐⭐ | v2.x | +| 🟢 P3 | 源代码全文搜索 | ⭐⭐ | ⭐⭐ | v2.x | +| 🟢 P3 | SAP 版本管理集成 | ⭐⭐ | ⭐⭐⭐ | v2.x | +| 🟢 P3 | Shell 自动补全 | ⭐ | ⭐ | v2.x | +| 🔵 P4 | 交互式终端 UI (TUI) | ⭐⭐ | ⭐⭐⭐ | v3.0 | +| 🔵 P4 | AI 辅助代码生成 | ⭐⭐⭐ | ⭐⭐⭐ | v3.0 | +| 🔵 P4 | MCP 服务器模式 | ⭐⭐⭐ | ⭐⭐ | v3.0 | +| 🔵 P4 | 智能代码审查 | ⭐⭐ | ⭐⭐⭐ | v3.0 | + +--- + +## 四、技术架构建议 + +### 4.1 近期架构优化(支持 v1.x 扩展) + +``` +sapcli/ +├── __init__.py +├── main.py # CLI 入口(拆分 argparse 到独立模块) +├── cli/ # 命令注册与解析(新增) +│ ├── parser.py # argparse 定义 +│ ├── completions.py # Shell 补全 +│ └── output.py # 格式化输出(替代散落各处的 print) +├── client.py # ADT REST 客户端(保持) +├── commands/ # 按功能拆分命令(新增目录) +│ ├── crud.py # download / sync / create / delete / info +│ ├── batch.py # init / refresh / sync-all +│ ├── transport.py # 传输请求管理(新增) +│ ├── search.py # list / whereused / search(新增) +│ └── check.py # syntax-check / atc(新增) +├── types.py # 对象类型注册(保持) +├── ddic.py # DDIC 定义(保持) +├── manifest.py # 清单管理(保持) +├── scanner.py # 目录扫描(保持) +├── sorter.py # 拓扑排序(保持) +├── config.py # 配置管理(增强多 profile) +├── auth.py # 凭证管理(新增:keyring 集成) +├── exceptions.py # 异常定义(保持) +└── utils/ # 工具函数(新增) + ├── xml_utils.py # XML 安全转义、解析 + ├── diff.py # 代码差异对比 + └── format.py # ABAP 格式化 +``` + +### 4.2 新增对象类型的标准流程 + +每次新增一个对象类型,需要修改的文件: + +| 步骤 | 文件 | 内容 | +|:-----|:-----|:-----| +| 1 | `types.py` | 注册 `ObjectTypeConfig`(URI 模板、content-type) | +| 2 | `client.py` | `_build_create_body()` 新增分支(或数据驱动) | +| 3 | `scanner.py` | `DIRECTORY_TYPE_MAP` 新增目录映射 | +| 4 | `commands.py` | 特殊逻辑处理(如有) | +| 5 | `ddic.py` | 如有 XML/DDL 定义需求 | + +### 4.3 建议引入的依赖 + +| 依赖 | 用途 | 时机 | +|:-----|:-----|:-----| +| `keyring` | 安全凭证存储 | v1.2 | +| `rich` | 终端美化输出(进度条、表格、语法高亮) | v1.3 | +| `textual` | 终端 UI 框架 | v3.0(可选) | +| `pydantic` | JSON 定义文件验证 | v2.0 | +| `httpx` | 替代 requests(支持 async、超时、重试) | v2.0 | + +--- + +## 五、竞品参考 + +| 项目 | 语言 | 特点 | 值得借鉴 | +|:-----|:-----|:-----|:---------| +| [abap-adt-api](https://github.com/marcellourbani/abap-adt-api) | TypeScript | 最全面的 ADT API 封装 | CDS View、AMDP、Where-Used、搜索 | +| [erpl-adt](https://github.com/DataZooDE/erpl-adt) | TypeScript | CLI + MCP 服务器 | MCP 协议、对象浏览、AI 集成 | +| [abapGit](https://github.com/abapGit/abapGit) | ABAP | SAP 端 Git 客户端 | 版本管理思想、serialize/deserialize | + +--- + +## 六、总结 + +sap-cli 已经建立了一个坚实的核心: +- **10 种对象类型**的完整 CRUD +- **批量同步** + 拓扑排序 + 传输请求管理 +- **manifest.json** 状态驱动的项目管理 + +最自然、最高价值的扩展路径是: + +``` +v1.1 Include + list + 消息类 → 补齐日常开发必需类型 +v1.2 多系统 + keyring → 企业级安全与多环境 +v1.3 CDS View + Where-Used → S/4HANA 现代化开发 +v1.4 传输管理 + 包操作 → 运维与管理能力 +v2.0 ATC + diff + 依赖分析 → 代码质量保障 +v2.x CI/CD + 模板 + 搜索 → DevOps 集成 +v3.0 TUI + AI + MCP → 智能化开发 +``` + +这个路径从 **"能用的工具"** 逐步演进到 **"不可替代的开发平台"**,每一步都有明确的用户价值。 diff --git a/docs/dev/test-coverage-report.md b/docs/dev/test-coverage-report.md new file mode 100644 index 0000000..f4e737e --- /dev/null +++ b/docs/dev/test-coverage-report.md @@ -0,0 +1,103 @@ +# sap-cli 测试覆盖率报告 + +> 测试时间:2026-06-09 +> 测试用例:68(63 pass + 5 skip) +> 覆盖率工具:coverage.py + +--- + +## 总体概况 + +| 指标 | 数值 | +|------|------| +| 源码总行数(Stmts) | 3,018 | +| 已覆盖行数 | 674 | +| **总体覆盖率** | **22%** | + +--- + +## 按模块覆盖率(从低到高) + +### 🔴 严重不足(< 20%)— 占总代码量 71% + +| 模块 | 代码行 | 覆盖率 | 未覆盖行 | 说明 | +|------|--------|--------|---------|------| +| `cli/parser.py` | 114 | **4%** | 110 | CLI 参数定义,仅被 import 触发 | +| `commands/crud.py` | 560 | **5%** | 533 | **最大模块**,5 个核心命令零覆盖 | +| `commands/batch.py` | 230 | **7%** | 215 | 批量操作 3 个命令零覆盖 | +| `client.py` | 716 | **7%** | 665 | **第二大的**,30 个 API 方法零覆盖 | +| `commands/cds.py` | 126 | **10%** | 114 | CDS 命令零覆盖 | +| `commands/quality.py` | 94 | **10%** | 85 | check + format 零覆盖 | +| `commands/search.py` | 100 | **10%** | 90 | list/whereused/search 零覆盖 | +| `commands/transport.py` | 100 | **10%** | 90 | 传输管理零覆盖 | +| `commands/config_cmd.py` | 75 | **11%** | 67 | config 命令零覆盖 | +| `commands/package_cmd.py` | 78 | **12%** | 69 | package 命令零覆盖 | +| `commands/diff_cmd.py` | 59 | **17%** | 49 | diff 命令零覆盖 | +| `auth.py` | 99 | **31%** | 68 | keyring 方法被测,cmd_auth 未测 | + +**小计:2,451 行未覆盖(占总量 81%)** + +### 🟡 部分覆盖(20%–79%) + +| 模块 | 代码行 | 覆盖率 | 说明 | +|------|--------|--------|------| +| `commands/analyze.py` | 62 | **34%** | 解析逻辑被测,命令入口未测 | +| `commands/scaffold.py` | 64 | **34%** | 4 个模板生成被测,命令入口未测 | +| `cli/output.py` | 25 | **60%** | print 函数被部分调用 | +| `scanner.py` | 52 | **62%** | 基础扫描被测,函数目录未测 | +| `manifest.py` | 82 | **70%** | CRUD 被测,文件 I/O 边界未测 | +| `config.py` | 62 | **77%** | 环境变量被测,profile/load 未测 | + +### 🟢 良好(≥ 80%) + +| 模块 | 代码行 | 覆盖率 | 说明 | +|------|--------|--------|------| +| `exceptions.py` | 35 | **83%** | 核心异常被测 | +| `ddic.py` | 128 | **84%** | XML/DDL 构建被测 | +| `types.py` | 69 | **90%** | 注册+解析被测,新注册 6 种未测 | +| `sorter.py` | 68 | **96%** | 几乎全覆盖 | +| `xml_utils.py` | 5 | **100%** | 全覆盖 | +| `__init__.py` 系列 | 15 | **100%** | 全覆盖 | + +--- + +## 三层覆盖率分析 + +``` +项目架构 代码行 覆盖率 未覆盖 +────────────────────────────────────────────── +CLI 层 parser+app 139 5% 132 +命令层 commands/* 1,548 8% 1,424 +API 层 client.py 716 7% 665 +基础层 其他模块 615 74% 159 +────────────────────────────────────────────── +合计 3,018 22% 2,344 +``` + +### 关键发现 + +1. **CLI 层 + 命令层 + API 层 = 2,403 行,覆盖率仅 7%** + — 这三层占总代码量的 80%,但几乎没有测试 + — 原因:现有 68 用例全部测的是基础层(types/config/ddic/manifest/sorter) + +2. **基础层覆盖率 74%** — 已经不错 + — types.py 的 6 种新注册类型需要补充测试 + — config.py 的 `load_config(profile=...)` 需要补充测试 + +3. **单文件代码量 Top 3**: + - `client.py` (716行) — 0 测试 ← 最大风险点 + - `commands/crud.py` (560行) — 0 测试 + - `commands/batch.py` (230行) — 0 测试 + +--- + +## 提升路径 + +| 目标覆盖率 | 需要新增测试 | 预计工时 | +|-----------|-------------|---------| +| 50% | client.py + commands 核心方法 (~130 用例) | 6h | +| 70% | 所有命令 + cli 入口 (~90 用例) | 4h | +| 80% | 边界条件 + 错误路径 (~50 用例) | 3h | +| 90% | 极端场景 + 集成测试 (~40 用例) | 3h | + +> HTML 详细报告已生成:`tests/coverage_html/index.html` diff --git a/docs/dev/test-plan.md b/docs/dev/test-plan.md new file mode 100644 index 0000000..13e2334 --- /dev/null +++ b/docs/dev/test-plan.md @@ -0,0 +1,344 @@ +# sap-cli 测试方案 + +> 版本:2.1 | 20 命令 · 16 类型 · 30 API 方法 +> 日期:2026-06-09 + +--- + +## 一、现状分析 + +### 1.1 代码架构(四层) + +``` +┌─────────────────────────────────────────────────┐ +│ CLI 层 parser.py + app.py │ ← 参数解析、命令路由 +├─────────────────────────────────────────────────┤ +│ 命令层 commands/*.py (11 模块, 20 命令) │ ← 业务编排 +├─────────────────────────────────────────────────┤ +│ API 层 client.py (30 方法) │ ← ADT REST 通信 +├─────────────────────────────────────────────────┤ +│ 基础层 types/config/auth/ddic/manifest/... │ ← 纯逻辑、无网络 +└─────────────────────────────────────────────────┘ +``` + +### 1.2 现有测试 + +| 文件 | 行数 | 状态 | 说明 | +|------|------|------|------| +| `tests/test_sapcli.py` | 695 | ✅ 活跃 | 68 用例(63 pass + 5 skip) | +| `tests/test_batch.py` | 840 | ⚠️ 过时 | 旧版批量测试,API 签名已变 | +| `tests/test_main.py` | 833 | ⚠️ 过时 | 旧版 E2E,引用旧入口 | + +**test_sapcli.py 覆盖的模块:** +- ✅ types.py(注册、解析)— 6 用例 +- ✅ config.py(环境变量)— 2 用例 +- ✅ auth.py(keyring 降级)— 4 用例 +- ✅ ddic.py(XML/DDL 构建)— 7 用例 +- ✅ manifest.py(增删查)— 5 用例 +- ✅ scanner.py(目录扫描)— 2 用例 +- ✅ sorter.py(拓扑排序)— 4 用例 +- ✅ exceptions.py — 2 用例 +- ✅ scaffold.py(模板生成)— 4 用例 +- ✅ xml_utils.py — 4 用例 +- ✅ XML 注入防御 — 3 用例 +- ✅ 模块结构检查 — 5 用例 +- ✅ OpenSpec 文件检查 — 3 用例 +- ✅ output.py — 3 用例 +- ✅ analyze.py — 4 用例 + +**未覆盖的模块(缺口):** +- ❌ client.py(30 个 API 方法)— 0 用例 +- ❌ commands/*.py(11 个命令模块)— 0 用例 +- ❌ cli/parser.py(参数校验)— 0 用例 +- ❌ cli/app.py(路由逻辑)— 0 用例 + +--- + +## 二、测试分层设计 + +### 分层策略 + +``` + ┌─────────────┐ + │ E2E 测试 │ ← 真实 SAP 系统 + │ (手动) │ 仅在上线前执行 + ├─────────────┤ + │ 集成测试 │ ← Mock HTTP Server + │ (自动化) │ 验证 client↔ADT 通信 + ├─────────────┤ + │ 单元测试 │ ← unittest.mock + │ (自动化) │ 验证各模块独立逻辑 + ├─────────────┤ + │ 静态分析 │ ← pylint / mypy + │ (CI 自动) │ 语法、类型、规范 + └─────────────┘ +``` + +| 层级 | 目标 | 技术 | 运行频率 | 依赖 | +|------|------|------|---------|------| +| 静态分析 | 语法/类型/规范 | pylint, mypy | 每次 commit | 无 | +| 单元测试 | 模块独立逻辑 | unittest + mock | 每次 commit | 无 | +| 集成测试 | API 通信正确性 | unittest + responses/mock HTTP | 每次 commit | 无 | +| E2E 测试 | 端到端真实流程 | 手动执行 | 发布前 | SAP 系统 | + +--- + +## 三、单元测试设计(优先级 P0) + +> 目标:每个模块的纯逻辑都有测试,不依赖网络。 + +### 3.1 保留现有 68 用例 + +全部保留,但修复 5 个 skip: +- `test_multi_profile` / `test_profile_fallback`:现在 config.py 已支持 `--profile`,应改为 pass +- `test_list_objects` / `test_pretty_print` / `test_transport_info`:方法已加回 client.py,应取消 skip + +### 3.2 新增用例清单 + +#### A. client.py — API 层(30 方法) + +每个方法测 3 个场景:成功响应、HTTP 错误、XML 解析。 + +| 方法 | 正常用例 | 错误用例 | mock 要点 | +|------|---------|---------|-----------| +| `login()` | 返回有效 session | 401 → LoginError | mock session.get | +| `lock()` | 返回 lock_handle + corr_nr | 403 → ObjectLockedError | mock session.post | +| `unlock()` | 返回 True | 非 200 返回 False | mock session.post | +| `set_source()` | 返回 True | 非 200 返回 False | mock session.put | +| `get_source()` | 返回源码字符串 | 404 → ObjectNotFoundError | mock session.get | +| `delete_object()` | 返回 (True, msg) | 非 200 (False, msg) | mock session.delete | +| `create_object()` | 返回 (obj_uri, src_uri) | 409 → ObjectAlreadyExistsError | mock session.post | +| `activate()` | 返回 (True, []) | 返回 (False, [errors]) | mock session.post | +| `syntax_check()` | 返回 (True, []) | 返回 (False, [errors]) | mock session.post | +| `get_object_status()` | 返回 status dict | 404 → ObjectNotFoundError | mock session.get | +| `object_exists()` | 返回 True/False | — | mock session.get | +| `list_objects()` | 返回 list[dict] | 空 → [] | mock session.get | +| `where_used()` | 返回 list[dict] | 无引用 → [] | mock session.post | +| `search_code()` | 返回 list[dict] | 无结果 → [] | mock session.post | +| `read_source_for_diff()` | 委托 get_source | — | mock self.get_source | +| `get_cds_source()` | 返回 DDL 字符串 | 404 → ObjectNotFoundError | mock session.get | +| `create_cds()` | 创建+写入+返回 URI | 失败 → SapCliError | mock lock/set_source/unlock | +| `create_package()` | 返回 True | 非 200 → False | mock session.post | +| `get_package_info()` | 返回 dict | 404 → SapCliError | mock session.get | +| `transport_info()` | 返回 dict | 404 → SapCliError | mock session.get | +| `transport_release()` | 返回 True | 非 200 → False | mock session.post | +| `transport_objects()` | 返回 list[dict] | 空 → [] | mock session.get | +| `atc_check()` | 返回 (bool, list) | 无发现 → (True, []) | mock session.post | +| `pretty_print()` | 返回格式化源码 | 失败 → SapCliError | mock session.post | +| `list_transport_requests()` | 返回 list[dict] | 空 → [] | mock session.get | +| `create_transport_request()` | 返回 corr_nr | 失败 → SapCliError | mock session.post | +| `get_transport_request()` | 返回 corr_nr 或 None | 空 → None | mock session.get | +| `create_function_group()` | 成功 | 失败 → SapCliError | mock session.post | +| `function_group_exists()` | True/False | — | mock session.get | +| `create_ddic_object()` | 成功写入 | 失败 → SapCliError | mock self._put_ddic_xml | + +**小计:~90 用例** + +#### B. commands/*.py — 命令层(11 模块) + +每个命令测:正常流程 + 错误处理 + 参数缺失。 + +| 命令 | 模块 | 正常用例 | 错误用例 | +|------|------|---------|---------| +| `create` | crud.py | 创建成功(4种模板+--source+--definition) | 对象已存在、类型不支持 | +| `download` | crud.py | 下载成功、文件名正确 | 对象不存在、无源码 URI | +| `sync` | crud.py | 五步同步成功 | 语法检查失败、激活失败 | +| `info` | crud.py | 显示元数据 | 对象不存在 | +| `delete` | crud.py | 确认删除、取消删除 | 对象不存在 | +| `init` | batch.py | 扫描→生成 manifest | 空目录、无 SAP 连接 | +| `refresh` | batch.py | 更新状态 | — | +| `sync --all` | batch.py | 批量同步+拓扑排序 | 循环依赖、fail-fast | +| `list` | search.py | 列出对象 | 空 | +| `whereused` | search.py | 找到引用 | 无引用 | +| `search` | search.py | 搜索结果 | 无结果 | +| `diff` | diff_cmd.py | 有差异/无差异 | — | +| `cds` | cds.py | 下载DDL/创建CDS | — | +| `package` | package_cmd.py | 创建/查询包 | — | +| `transport` | transport.py | 列出/详情/释放 | — | +| `check` | quality.py | ATC检查有/无发现 | — | +| `format` | quality.py | 格式化成功 | — | +| `analyze` | analyze.py | 分析依赖 | 无依赖 | +| `scaffold` | scaffold.py | 4种模板+无模板列出 | — | +| `config` | config_cmd.py | show/list-profiles/set | — | +| `auth` | auth.py | login/logout/status | keyring 不可用 | + +**小计:~60 用例** + +#### C. cli/ — 入口层 + +| 测试场景 | 说明 | +|---------|------| +| 无参数 → exit(1) + 帮助 | `app.py` 检测 no command | +| --profile DEV | 读 [DEV] section | +| --config 自定义路径 | 读指定文件 | +| config set host 1.2.3.4 | 写入 config.ini | +| config set invalid_key | 报错 | +| auth status | keyring 状态 | +| scaffold 无 --template | 列出模板 | +| scaffold --template xxx --name ZZZ | 生成模板 | + +**小计:~10 用例** + +### 3.3 单元测试总计 + +| 类别 | 现有 | 新增 | 合计 | +|------|------|------|------| +| 基础层 | 68 | 0 | 68 | +| API 层 (client.py) | 0 | ~90 | 90 | +| 命令层 (commands/) | 0 | ~60 | 60 | +| 入口层 (cli/) | 0 | ~10 | 10 | +| **总计** | **68** | **~160** | **~228** | + +--- + +## 四、集成测试设计(优先级 P1) + +> 目标:验证 client.py 与真实 ADT API 的通信协议是否正确。 + +### 4.1 技术方案:`responses` 库 mock HTTP + +```python +import responses + +@responses.activate +def test_login_success(): + responses.add(responses.GET, "https://sap.example.com/sap/bc/adt/...", + status=200, headers={"x-csrf-token": "TOKEN123"}) + client = ADTClient("sap.example.com", "100", "USER", "PASS") + client.login() + assert client.csrf_token == "TOKEN123" +``` + +### 4.2 集成测试用例 + +按 ADT API 端点分组,验证 HTTP 方法和 XML 请求体: + +| 端点组 | 测试场景 | 数量 | +|--------|---------|------| +| 认证 | login → CSRF token 获取 | 2 | +| 锁管理 | lock/unlock → 正确的 If-Match header | 4 | +| 源码读写 | get/set_source → 正确的 Content-Type | 4 | +| 对象 CRUD | create/delete → 正确的 XML body | 6 | +| DDIC | create_ddic → 正确的 XML namespace | 4 | +| 激活/检查 | activate/syntax_check → 正确的 XML 响应解析 | 4 | +| 搜索 | list/search/whereused → URL 参数编码 | 4 | +| 传输 | transport list/info/release → URL 拼接 | 4 | +| 质量 | atc_check/pretty_print → 请求体格式 | 4 | +| **合计** | | **~36** | + +--- + +## 五、E2E 测试设计(优先级 P2) + +> 目标:端到端验证完整用户场景。 + +### 5.1 前置条件 + +- 需要一台可连接的 SAP 系统 +- 使用测试用户和 `$TMP` 包 +- 所有操作可逆(删除测试对象) + +### 5.2 测试场景 + +| # | 场景 | 步骤 | 验证点 | +|---|------|------|--------| +| E1 | 完整 CRUD | create → info → download → sync → delete | 每步输出正确,对象最终不存在 | +| E2 | DDIC 对象 | create domain → create dataelement → create table | 依赖顺序正确 | +| E3 | 批量同步 | init → 修改本地文件 → sync --all | 所有对象同步成功 | +| E4 | 多 Profile | config set → --profile DEV → download | 读取正确配置 | +| E5 | 传输管理 | create transport → create object → release | 传输号关联正确 | + +**E2E 测试建议用手动执行**,因为: +- 依赖真实 SAP 系统可用性 +- 测试数据需要隔离 +- 某些操作不可逆(如 transport release) + +### 5.3 E2E 自动化(可选) + +如果 SAP 测试系统长期可用,可用 `tests/e2e_project/` 现有的测试数据做半自动 E2E: + +```bash +python main.py init --path tests/e2e_project +python main.py sync --all --path tests/e2e_project +``` + +--- + +## 六、现有测试处理 + +| 文件 | 处理 | 原因 | +|------|------|------| +| `tests/test_sapcli.py` | ✅ 保留并扩展 | 当前唯一活跃测试 | +| `tests/test_batch.py` | ❌ 废弃 | 840 行,API 签名已变,与新架构不兼容 | +| `tests/test_main.py` | ❌ 废弃 | 833 行,引用旧入口 `src/` 路径 | +| `tests/test_e2e.md` | ✅ 保留 | E2E 手动测试说明仍有参考价值 | + +--- + +## 七、测试文件组织 + +``` +tests/ +├── conftest.py # 共享 fixture(SAPConfig mock、临时目录) +├── test_sapcli.py # 基础层测试(保留现有 68 → 修复 5 skip) +├── unit/ # 新增:单元测试 +│ ├── test_client.py # client.py 30 方法 +│ ├── test_commands.py # commands/ 11 模块 +│ └── test_cli.py # parser + app 路由 +├── integration/ # 新增:集成测试 +│ ├── test_adt_auth.py # 认证通信 +│ ├── test_adt_crud.py # CRUD 通信 +│ ├── test_adt_search.py # 搜索通信 +│ ├── test_adt_transport.py # 传输通信 +│ └── test_adt_quality.py # 质量通信 +├── e2e/ # E2E 手动测试 +│ └── test_e2e.md # 手动测试说明 +├── e2e_project/ # E2E 测试数据 +│ ├── manifest.json +│ ├── reports/ +│ ├── classes/ +│ └── functions/ +└── fixtures/ # 测试固件 + ├── adt_responses/ # ADT XML 响应样本 + │ ├── login_success.xml + │ ├── object_info.xml + │ └── syntax_errors.xml + └── sample_source/ # 示例 ABAP 源码 + ├── zhello.abap + └── zcl_class.abap +``` + +--- + +## 八、实施优先级 + +| 阶段 | 内容 | 用例数 | 预计工时 | +|------|------|--------|---------| +| **P0** | 修复 5 个 skip + 清理旧测试 | +5 pass, -2 文件 | 1h | +| **P0** | client.py 单元测试 | ~90 | 4h | +| **P1** | commands/ 单元测试 | ~60 | 3h | +| **P1** | cli/ 入口测试 | ~10 | 1h | +| **P2** | 集成测试(mock HTTP) | ~36 | 3h | +| **P3** | E2E 手动测试 | 5 场景 | 2h | +| | **合计** | **~228** | **~14h** | + +### 立即可做(本次) + +1. 修复 5 个 skip 用例 → 68 pass +2. 清理旧测试文件 → 减少 1673 行过时代码 +3. 新建测试目录结构 +4. 从 client.py 核心方法开始写单元测试 + +--- + +## 九、关键技术选型 + +| 工具 | 用途 | 是否需要安装 | +|------|------|-------------| +| `unittest` | 测试框架 | ✅ 内置 | +| `unittest.mock` | mock client/requests | ✅ 内置 | +| `responses` | mock HTTP 响应(集成测试) | 需要 `pip install responses` | +| `tempfile` | 临时文件/目录 | ✅ 内置 | +| `pytest` | 可选:更简洁的断言 + fixture | 需要 `pip install pytest` | + +**建议**:保持 `unittest` 框架(不引入 pytest),减少外部依赖。集成测试用 `unittest.mock.patch` 替代 `responses`。 diff --git a/docs/dev/test-report.md b/docs/dev/test-report.md new file mode 100644 index 0000000..0e4a94a --- /dev/null +++ b/docs/dev/test-report.md @@ -0,0 +1,241 @@ +# ADT 工具集测试报告 + +> 测试目标:验证 `main.py` 的 create / info / download / sync / delete 五大功能在各种场景下的正确性。 +> +> 测试日期:2026-05-20 | 测试脚本:[test_main.py](file:///d:/codeSpace/Gitee/Projects2026/sap-cli/test_main.py) + +## 前置条件 + +- [ ] SAP 系统可连接(配置文件 `config.ini` 正确) +- [ ] Python 3.10+ 和 `requests` 库已安装 +- [ ] 系统中存在至少一个 Modifiable 状态的传输请求 +- [ ] 系统中存在以下已有对象用于查询测试: + - class: `ZCL_SAP_MCP_HANDLER` + - report: `ZIDTR_IMPORT_BOM` + - domain: `ZSAPILOT_OBJECT_TYPE` + - dataelement: `ZSAPILOT_OBJECT_NAME` + - table: `ZSAPILOT_OBJ` + +--- + +## 测试覆盖矩阵 + +> 操作(行) × 对象类型(列) 的交叉覆盖情况。 +> ✅ = 已测试通过 | ⬜ = 不适用 | ➖ = 未覆盖(系统限制) + +| 操作 \ 对象类型 | report | class | function | functiongroup | interface | domain | dataelement | table | tabletype | +|:---------------|:------:|:-----:|:--------:|:-------------:|:---------:|:------:|:-----------:|:-----:|:---------:| +| **create** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC3 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **info** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ | ✅ SC4 | ✅ SC9 | ✅ SC9 | ✅ SC9| ✅ SC9 | +| **download** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC13 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **sync** | ✅ SC1,SC5 | ✅ SC2 | ✅ SC3 | ✅ SC13 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | +| **delete** | ✅ SC1 | ✅ SC2 | ✅ SC3 | ✅ SC3 | ✅ SC4 | 🔒 | 🔒 | 🔒 | ⬜ | + +> **图例:** +> - ✅ 已测试通过 +> - ⬜ 不适用(VIT 端点不支持源码读写和锁定操作) +> - 🔒 SAP 系统限制(当前目标系统 NW 7.40 的 ADT API 不支持 DDIC 对象的源码读写和创建/删除) + +> **DDIC 对象(🔒)说明:** +> - `info` 可用(使用 `Accept: */*` 头),已在 SC9 验证 +> - `create/download/sync/delete` 不可用 — 经实际探测,SAP NetWeaver 7.40 的 ADT 框架对 DDIC 对象(domain/dataelement/table)不提供源码读写端点(返回 404/406) +> - 在 SAP S/4HANA 或更高版本 NetWeaver 系统上,这些操作可能可用 +> - 代码层面已实现完整支持(URI 映射、创建模板、argparse choices 均已配置),仅受目标系统 API 限制 + +> **tabletype(⬜)说明:** +> - `info` 已可用 — 通过 VIT 端点(`/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}`)查询元数据,已在 SC9 验证 +> - `download/sync/delete` 不可用 — VIT 端点为只读元数据接口,不提供源码读写和锁定操作 +> - `create` 不可用 — 无可用的集合端点用于创建新对象 + +--- + +## 场景测试详情 + +### 场景组 happy — 正向完整流程 + +> 核心流程:创建 → 查询 → 下载 → 修改 → 同步激活 → 验证修改 → 删除 → 验证不存在 + +#### 场景 1: 正向完整流程 — report + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC1-1 | 创建 report | `create --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 | ✅ | +| SC1-2 | 查询验证 | `info --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 且名称匹配 | ✅ | +| SC1-3 | 下载源码 | `download --name ZTEST_SC1_RPT_xxx --type report` | rc == 0 且文件存在 | ✅ | +| SC1-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC1-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC1-6 | 删除对象 | `delete --name ZTEST_SC1_RPT_xxx` | 删除成功 | ✅ | +| SC1-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 2: 正向完整流程 — class + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC2-1 | 创建 class | `create --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 | ✅ | +| SC2-2 | 查询验证 | `info --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 且名称匹配 | ✅ | +| SC2-3 | 下载源码 | `download --name ZTEST_SC2_CLS_xxx --type class` | rc == 0 且文件存在 | ✅ | +| SC2-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC2-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC2-6 | 删除对象 | `delete --name ZTEST_SC2_CLS_xxx` | 删除成功 | ✅ | +| SC2-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 3: 正向完整流程 — function + functiongroup + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC3-1 | 创建函数组 | `create --name ZTEST_SC3_FG_xxx --type functiongroup` | rc == 0 | ✅ | +| SC3-2 | info 验证函数组存在 | `info --name ZTEST_SC3_FG_xxx --type functiongroup` | rc == 0 且名称匹配 | ✅ | +| SC3-3 | 创建函数 | `create --name ZTEST_SC3_FG_xxx/ZTEST_SC3_FM_xxx --type function` | rc == 0 | ✅ | +| SC3-4 | info 验证函数存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC3-5 | 下载函数源码 | `download` | rc == 0 且文件存在 | ✅ | +| SC3-6 | 首次修改 → 同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC3-7 | 再次下载验证修改持久化 | `download` | rc == 0 且文件包含修改标记 | ✅ | +| SC3-7b | 验证修改内容已持久化 | 读取下载文件 | 包含 "SC3 TEST MARKER" | ✅ | +| SC3-8 | 二次修改 → 同步激活 | 替换标记 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC3-9 | info 验证函数仍存在 | `info --name ... --type function` | rc == 0 且名称匹配 | ✅ | +| SC3-10 | info 验证函数组仍存在 | `info --name ... --type functiongroup` | rc == 0 且名称匹配 | ✅ | +| SC3-11 | 删除函数 | `delete` 函数模块 | 删除成功 | ✅ | +| SC3-12 | info 验证函数组在函数删除后仍存在 | `info --name ... --type functiongroup` | rc == 0(函数组独立于函数) | ✅ | +| SC3-13 | 删除函数组 | `delete` 函数组 | 删除成功 | ✅ | +| SC3-14 | info 验证函数组已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 4: 正向完整流程 — interface + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC4-1 | 创建 interface | `create --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 | ✅ | +| SC4-2 | 查询验证 | `info --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 且名称匹配 | ✅ | +| SC4-3 | 下载源码 | `download --name ZTEST_SC4_INT_xxx --type interface` | rc == 0 且文件存在 | ✅ | +| SC4-4 | 修改后同步激活 | 修改源码 → `sync` | 语法检查通过 + 激活成功 | ✅ | +| SC4-5 | 验证修改持久化 | 再次 `download` | 源码包含修改标记 | ✅ | +| SC4-6 | 删除对象 | `delete --name ZTEST_SC4_INT_xxx` | 删除成功 | ✅ | +| SC4-7 | 验证已不存在 | `info` | rc != 0 | ✅ | + +--- + +### 场景组 error — 逆向错误处理 + +#### 场景 5: 逆向 — 语法错误 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC5-1 | 创建 report | `create` | rc == 0 | ✅ | +| SC5-2 | 下载源码 | `download` | rc == 0 且文件存在 | ✅ | +| SC5-3 | 注入无效语法 → 同步 | 添加 `INVALID_SYNTAX_HERE.` → `sync` | rc != 0 且包含错误信息 | ✅ | +| SC5-4 | 恢复源码 → 重新同步 | 还原源码 → `sync` | rc == 0 + 激活成功 | ✅ | +| SC5-5 | 清理删除 | `delete` | 删除成功 | ✅ | + +#### 场景 6: 逆向 — 对象不存在 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC6-1 | 查询不存在的 class | `info --name Z_NOT_EXIST_99999 --type class` | rc != 0 且包含"不存在" | ✅ | +| SC6-2 | 下载不存在的 report | `download --name Z_NOT_EXIST_99999` | rc != 0 | ✅ | +| SC6-3 | 删除不存在的 report | `delete --name Z_NOT_EXIST_99999` | rc != 0 | ✅ | + +#### 场景 7: 逆向 — 重复创建 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC7-1 | 首次创建 | `create --name ZTEST_SC7_RPT_xxx` | rc == 0 | ✅ | +| SC7-2 | 重复创建 → 报错 | 再次 `create` 同名对象 | rc != 0 | ✅ | +| SC7-3 | 清理删除 | `delete` | 删除成功 | ✅ | + +--- + +### 场景组 edge — 边界场景 + +#### 场景 8: 边界 — 同步自动创建 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC8-1 | 准备本地源码 | 创建 .abap 文件 | 文件存在 | ✅ | +| SC8-2 | sync 自动创建 | `sync` 不存在的对象 | rc == 0(自动创建) | ✅ | +| SC8-3 | info 验证存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC8-4 | 清理删除 | `delete` | 删除成功 | ✅ | + +#### 场景 9: 边界 — DDIC 对象查询 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC9-1 | info domain | `info --name ZSAPILOT_OBJECT_TYPE --type domain` | rc == 0 且名称匹配 | ✅ | +| SC9-2 | info dataelement | `info --name ZSAPILOT_OBJECT_NAME --type dataelement` | rc == 0 | ✅ | +| SC9-3 | info table | `info --name ZSAPILOT_OBJ --type table` | rc == 0 | ✅ | +| SC9-4 | info tabletype | `info --name ZSAPILOT_OBJECT_TT --type tabletype` | rc == 0 且名称匹配 | ✅ | + +#### 场景 10: 边界 — 删除安全确认 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC10-1 | 创建 report | `create` | rc == 0 | ✅ | +| SC10-2 | delete 输入 no → 取消 | `delete` stdin="no" | 输出包含"已取消" | ✅ | +| SC10-3 | info 验证仍存在 | `info` | rc == 0 且名称匹配 | ✅ | +| SC10-4 | delete 输入 yes → 成功 | `delete` stdin="yes" | 删除成功 | ✅ | +| SC10-5 | info 验证已不存在 | `info` | rc != 0 | ✅ | + +#### 场景 13: 边界 — functiongroup 不支持源码操作 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC13-1 | download functiongroup → 报错 | `download --name ZIDT_MCP_TOOL --type functiongroup` | rc != 0 且包含"不支持" | ✅ | +| SC13-2 | sync functiongroup → 报错 | `sync --name ZIDT_MCP_TOOL --type functiongroup` | rc != 0 且包含"不支持" | ✅ | + +--- + +### 场景组 existing — 已有对象操作 + +#### 场景 11: 已有对象查询 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC11-1 | info 已有 class | `info --name ZCL_SAP_MCP_HANDLER --type class` | rc == 0 且名称匹配 | ✅ | +| SC11-2 | 下载已有 class | `download --name ZCL_SAP_MCP_HANDLER` | rc == 0 且文件存在 | ✅ | +| SC11-3 | 同步无修改 → 成功 | `sync`(源码未修改) | 语法检查通过 + 激活成功 | ✅ | + +--- + +### 场景组 config — 配置与环境 + +#### 场景 12: 配置与环境 + +| 编号 | 步骤 | 操作 | 验证条件 | 结果 | +|:----:|------|------|---------|:----:| +| SC12-1 | 默认配置运行 | `download`(使用 config.ini) | rc == 0 | ✅ | +| SC12-2 | 无效 SAP_HOST | 设置 `SAP_HOST=http://invalid:9999` | rc != 0 | ✅ | + +--- + +## 测试统计 + +### 按场景组统计 + +| 场景组 | 场景数 | 步骤总数 | ✅ 通过 | ❌ 失败 | 通过率 | +|--------|:------:|:--------:|:-------:|:-------:|:------:| +| happy(正向完整流程) | 4 | 36 | 36 | 0 | 100% | +| error(逆向错误处理) | 3 | 11 | 11 | 0 | 100% | +| edge(边界场景) | 4 | 14 | 14 | 0 | 100% | +| existing(已有对象) | 1 | 3 | 3 | 0 | 100% | +| config(配置环境) | 1 | 2 | 2 | 0 | 100% | +| **合计** | **13** | **66** | **66** | **0** | **100%** | + +### 失败项分析 + +本次测试全部通过,无失败项。 + +### 测试过程中发现并修复的问题 + +| # | 问题 | 修复内容 | +|---|------|---------| +| 1 | 下载文件含 `\r\r\n` 双重回车 | 保存前统一换行符 `source.replace("\r\n", "\n")` | +| 2 | `lock()` 未传 corrNr 导致写入冲突 | `lock()` 增加 `corr_nr` 参数 | +| 3 | `activate()` 误判 inactiveObjects 为成功 | 新增 Content-Type 检测,正确处理 `inactiveCtsObjects` 响应 | +| 4 | `activate()` 使用 `preauditRequested=true` 导致激活失败 | 移除该参数 | +| 5 | `_headers()` 硬编码 `Accept-Language: EN` | 移除硬编码,使用 SAP 系统登录语言 | +| 6 | `cmd_sync` 交互式等待用户输入 | 改为非交互式,报错直接退出 | +| 7 | `cmd_sync` 缺少语法检查步骤 | 新增 `syntax_check()` 方法,激活前先做语法检查 | +| 8 | `cmd_sync` 对象不存在时直接退出 | 改为自动创建空对象后继续同步流程 | +| 9 | `create` 缺少 interface 默认模板 | 新增 interface 模板到 `DEFAULT_TEMPLATES` | +| 10 | `parse_object_name` 对 functiongroup 崩溃(src_uri 为 None) | 增加 `if config["src_uri_template"] else None` | +| 11 | `cmd_info` 缺少名称校验 | 新增查询结果名称与请求名称的对比校验 | +| 12 | `cmd_info` function 名称校验失败 | 用 `split("/")[-1]` 取实际对象名进行校验 | +| 13 | DDIC 对象 info 查询 Accept 头不正确 | 改用 `*/*` Accept 头 | diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..141d749 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,712 @@ +--- +title: ADT 工具集使用指南 +created: 2026-05-20 +updated: 2026-05-20 +tags: + - SAP + - ADT + - REST + - python + - tools +parent: "[[sap-cli/README|SAP ADT 学习笔记总览]]" +--- + +# ADT 工具集使用指南 — `main.py` + +> [!abstract] 概述 +> `main.py` 是一个基于 SAP ADT REST API 的命令行工具,支持 **report**、**class**、**function**、**functiongroup**、**interface**、**domain**、**dataelement**、**table**、**tabletype** 等 ABAP 开发对象的创建、信息查询、源代码下载、同步激活和删除操作。 + +> [!info] 快速查阅 +> **命令格式:** `python main.py <命令> --name <名称> --type <类型> [参数]` +> +> | 命令 | 功能 | 完整参数 | +> |------|------|---------| +> | `create` | 创建对象 | `--name` `--type` `[--description]` `[--source]` `[--corr_nr]` | +> | `info` | 查询元数据(状态/描述/时间) | `--name` `--type` | +> | `download` | 下载源码到本地 `.abap` | `--name` `--type` `--path <目录>` | +> | `sync` | 上传源码 → 语法检查 → 激活 | `--name` `--type` `--path <.abap文件>` `[--corr_nr]` | +> | `delete` | 删除对象(需确认) | `--name` `--type` | +> +> **对象类型:** `report` `class` `function` `functiongroup` `interface` `domain` `dataelement` `table` `tabletype` +> +> **sync 流程:** 登录 → 检查/创建对象 → 锁定 → 写入 → 解锁 → 语法检查 → 激活 → 报错则退出(修改后重新执行) +> +> **典型用法:** +> ```bash +> python main.py create --name ZCL_MY_CLASS --type class +> python main.py info --name ZCL_MY_CLASS --type class +> python main.py sync --name ZCL_MY_CLASS --type class --path ./zcl_my_class.abap +> ``` + +## 1. 前置条件 + +### 环境要求 + +- Python 3.10+ +- 依赖库:`requests` + +### 安装依赖 + +```bash +pip install requests +``` + +### 连接配置 + +连接参数通过配置文件 `config.ini` 管理,位于脚本同目录下: + +```ini +[SAP] +host = http://support.learningleader.com.cn:55955 +client = 100 +user = admin2 +password = 654321 +``` + +| 参数 | 说明 | +|------|------| +| `host` | SAP 服务器地址(不含 `/sap/bc/adt` 路径) | +| `client` | SAP 客户端编号 | +| `user` | 登录用户名 | +| `password` | 登录密码 | + +> [!tip] 配置文件加载顺序 +> 脚本按以下顺序查找配置: +> 1. `--config` 参数指定的文件路径 +> 2. 环境变量 `SAP_HOST` / `SAP_CLIENT` / `SAP_USER` / `SAP_PASSWORD`(优先级高于配置文件) +> 3. 当前工作目录下的 `config.ini` +> 4. 脚本同目录下的 `config.ini` + +## 2. 对象类型说明 + +工具支持以下 ABAP 开发对象类型: + +| 类型参数 | 对象 | SAP GUI 事务码 | 名称格式 | 支持的操作 | +|---------|------|--------------|---------|----------| +| `report` | 程序 / 报表 | SE38 / SA38 | 直接使用程序名 | 创建、查询、下载、同步、删除 | +| `class` | 类 (ABAP Class) | SE24 | 直接使用类名 | 创建、查询、下载、同步、删除 | +| `function` | 函数模块 (Function Module) | SE37 | `函数组名/函数模块名` | 创建、查询、下载、同步、删除 | +| `functiongroup` | 函数组 (Function Group) | SE37 / SE80 | 直接使用函数组名 | 创建、查询、删除 | +| `interface` | 接口 (ABAP Interface) | SE24 | 直接使用接口名 | 创建、查询、下载、同步、删除 | +| `domain` | 域 (Domain) | SE11 | 直接使用域名 | 创建、同步 | +| `dataelement` | 数据元素 (Data Element) | SE11 | 直接使用元素名 | 创建、同步 | +| `table` | 透明表 (Table) | SE11 | 直接使用表名 | 创建、同步 | +| `tabletype` | 表类型 (Table Type) | SE11 | 直接使用类型名 | 创建、查询 ² | + +> [!warning] function 类型的名称格式 +> function 类型必须使用 **`函数组名/函数模块名`** 格式,用 `/` 分隔。例如:`ZGROUP/Z_MY_FUNC` +> +> report、class 和 functiongroup 类型直接使用对象名称即可。 + +## 3. 命令一览 + +``` +python main.py <命令> --name <对象名称> --type <对象类型> [其他参数] +``` + +| 命令 | 功能 | 必需参数 | +|------|------|---------| +| `create` | 在 SAP 系统创建开发对象 | `--name`, `--type` | +| `info` | 查询对象元数据(描述、激活状态等) | `--name`, `--type` | +| `download` | 从 SAP 下载源代码到本地 | `--name`, `--type`, `--path` | +| `sync` | 上传本地代码到 SAP,语法检查并激活 | `--name`, `--type`, `--path` | +| `delete` | 从 SAP 系统删除对象 | `--name`, `--type` | + +## 4. 功能详解 + +### 4.1 创建开发对象 (`create`) + +在 SAP 系统中创建新的开发对象。自动生成默认源代码模板并激活。 + +#### 执行流程 + +``` +登录 → 检查对象不存在? + ├─ 已存在 → 报错停止 + └─ 不存在 → 继续 + 选择传输请求(交互式) + ├─ 使用已有请求号(用户输入编号) + ├─ 新建请求号(用户提供描述) + └─ 不使用请求号(本地对象) + 创建对象 → 写入源代码 → 激活 +``` + +#### 命令格式 + +```bash +python main.py create --name <对象名> --type <类型> [--description <描述>] [--source <源代码文件>] [--corr_nr <传输请求号>] +``` + +#### 参数说明 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--name` | 是 | 对象名称(function 类型用 `组名/模块名`) | +| `--type` | 是 | 对象类型:`report` / `class` / `function` / `functiongroup` | +| `--description` | 否 | 对象描述,默认使用对象名称 | +| `--source` | 否 | 源代码文件路径(`.abap`),不指定则使用内置默认模板 | +| `--corr_nr` | 否 | 直接指定传输请求号(跳过交互选择) | + +#### 示例 + +```bash +# 创建程序(交互式选择传输请求) +python main.py create --name ZMY_REPORT --type report --description "我的报表程序" + +# 创建程序(直接指定传输请求号,跳过交互) +python main.py create --name ZMY_REPORT --type report --description "我的报表程序" --corr_nr DEVK901362 + +# 创建类(使用默认模板) +python main.py create --name ZCL_MY_CLASS --type class --description "我的类" + +# 创建函数组 +python main.py create --name ZMY_FGROUP --type functiongroup --description "我的函数组" + +# 创建函数模块(自动检测并创建函数组) +python main.py create --name ZMY_FGROUP/ZMY_FUNC --type function --description "我的函数模块" + +# 使用自定义源代码创建程序 +python main.py create --name ZMY_REPORT --type report --source ./my_code.abap +``` + +#### 输出示例 + +``` +============================================================ + SAP ADT 创建开发对象 +============================================================ + 对象类型: 程序(REPORT) + 对象名称: ZMY_REPORT + 描述: 我的报表程序 + + → 检查对象是否存在... + ✓ 对象不存在,可以创建 + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 已选择传输请求: DEVK901362 + + → 正在创建对象... + ✓ 对象创建成功! + ✓ URI: /sap/bc/adt/programs/programs/zmy_report + ✓ 源代码已写入并激活 (52 字符) + + ✓ ZMY_REPORT 创建完成! + 类型: 程序(REPORT) + 描述: 我的报表程序 +``` + +> [!tip] 传输请求选择 +> 创建对象时,脚本会列出所有可修改(Modifiable)状态的传输请求供选择。 +> - 选择已有请求:直接输入编号 +> - 新建请求:选择"新建传输请求",然后输入描述 +> - 本地对象:选择"不使用传输请求",对象将创建在 `$TMP` 包中 +> - 非交互模式:使用 `--corr_nr` 参数直接指定传输请求号 + +#### 默认源代码模板 + +不指定 `--source` 时,使用以下内置模板: + +**report 模板:** +```abap +REPORT ZMY_REPORT. +WRITE: / 'Hello from ZMY_REPORT'. +``` + +**class 模板:** +```abap +CLASS ZCL_MY_CLASS DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + PUBLIC SECTION. + METHODS: hello. +ENDCLASS. +CLASS ZCL_MY_CLASS IMPLEMENTATION. + METHOD hello. + ENDMETHOD. +ENDCLASS. +``` + +**function 模板:** +```abap +FUNCTION ZMY_FUNC + EXPORTING + VALUE(EV_RESULT) TYPE STRING. + ev_result = 'hello'. +ENDFUNCTION. +``` + +> [!tip] 创建函数模块时自动创建函数组 +> 创建 function 类型时,脚本会自动检查函数组是否存在。如果不存在,会先自动创建函数组,再创建函数模块。 + +--- + +### 4.2 源代码下载 (`download`) + +从 SAP 系统下载对象的源代码,保存为本地 `.abap` 文件。 + +#### 执行流程 + +``` +登录 → 检查对象存在 → 获取源代码 → 保存为 .abap 文件 +``` + +#### 命令格式 + +```bash +python main.py download --name <对象名> --type <类型> --path <保存目录> +``` + +#### 示例 + +```bash +# 下载 report +python main.py download --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./output + +# 下载 class +python main.py download --name ZCL_MY_CLASS --type class --path ./output + +# 下载 function +python main.py download --name ZGROUP/Z_MY_FUNC --type function --path ./output +``` + +#### 输出 + +- 在 `--path` 指定目录下生成 **同名小写** 的 `.abap` 文件 +- 例如:`--name ZMY_REPORT` → `./output/zmy_report.abap` +- 如果保存目录不存在,会自动创建 + +#### 输出示例 + +``` +============================================================ + SAP ADT 源代码下载 +============================================================ + 对象类型: 程序(REPORT) + 对象名称: ZIDTR_IMPORT_ACCOUNTING_ORDER + 保存路径: ./output + + → 正在登录... + ✓ 登录成功 + + → 检查对象是否存在... + ✓ 对象存在 + + → 正在下载源代码... + ✓ 源代码下载成功! 37655 字符, 1096 行 + + ✓ 文件已保存: ./output\zidtr_import_accounting_order.abap + + ✅ 下载完成! + 文件: ./output\zidtr_import_accounting_order.abap + 大小: 37655 字符, 1096 行 +``` + +--- + +### 4.3 源代码同步激活 (`sync`) + +将本地 `.abap` 文件的代码上传到 SAP 系统,进行语法检查并激活。**报错时直接退出,由调用者修改代码后重新执行**。 + +#### 执行流程 + +``` +登录 → 检查对象是否存在? + ├─ 不存在 → 自动创建空对象 → 继续 + └─ 存在 → 继续 + 锁定对象(自动检测传输请求绑定) + ├─ 对象已绑定请求号 → 自动使用原请求号 + ├─ 本地对象($TMP)→ 无需请求号 + └─ 需要请求号但未绑定 → 交互选择(已有/新建) + 写入 → 解锁 + → 语法检查 + ├─ 有错误 → 输出错误信息 → 退出(exit code 1) + └─ 无错误 → 继续 + → 激活 + ├─ 成功 → 输出成功信息 → 退出(exit code 0) + └─ 失败 → 输出错误信息 → 退出(exit code 1) +``` + +> [!important] 非交互式设计 +> 脚本发现语法错误或激活失败时,**不会暂停等待用户输入**,而是直接输出错误信息并以 exit code 1 退出。 +> 调用者需要根据错误信息修改本地 `.abap` 文件,然后**重新执行** `sync` 命令。 + +> [!tip] 传输请求自动检测 +> `sync` 命令会**先尝试锁定对象**,通过 SAP 系统返回的信息自动判断传输请求绑定状态: +> - 如果对象已绑定到某个请求号 → 直接使用该请求号,无需用户干预 +> - 如果对象是本地对象(`$TMP`)→ 不需要请求号 +> - 只有当对象需要请求号但尚未绑定时,才会提示用户选择或新建请求号 + +#### 命令格式 + +```bash +python main.py sync --name <对象名> --type <类型> --path <.abap文件路径> [--corr_nr <传输请求号>] +``` + +#### 参数说明 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--name` | 是 | 对象名称(function 类型用 `组名/模块名`) | +| `--type` | 是 | 对象类型 | +| `--path` | 是 | 本地源代码文件路径(`.abap`) | +| `--corr_nr` | 否 | 直接指定传输请求号(跳过自动检测) | + +#### 示例 + +```bash +# 同步激活 report(自动检测传输请求绑定) +python main.py sync --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./zidtr_import_accounting_order.abap + +# 同步激活 report(直接指定传输请求号) +python main.py sync --name ZIDTR_IMPORT_ACCOUNTING_ORDER --type report --path ./zidtr_import_accounting_order.abap --corr_nr DEVK901362 + +# 同步激活 class +python main.py sync --name ZCL_MY_CLASS --type class --path ./zcl_my_class.abap + +# 同步激活 function +python main.py sync --name ZGROUP/Z_MY_FUNC --type function --path ./z_my_func.abap +``` + +#### 输出示例 — 成功(对象已绑定请求号) + +``` +============================================================ + SAP ADT 源代码同步激活 +============================================================ + 对象类型: 类(CLASS) + 对象名称: ZCL_MY_CLASS + 本地文件: ./zcl_my_class.abap + 源代码: 256 字符, 12 行 + 流程: 检查/创建 → 写入 → 语法检查 → 激活 + + → 检查对象是否存在... + ✓ 对象存在 + +──────────────────────────────────────────────────────────── + + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ✓ 锁定成功(对象已绑定传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 + + ── Step 2/4: 语法检查 ── + ✓ 语法检查通过 + + ── Step 3/4: 激活对象 ── + ✓ 激活成功! + + ============================================================ + ✓ ZCL_MY_CLASS 已成功同步并激活! + ============================================================ +``` + +#### 输出示例 — 对象未绑定请求号(需要选择) + +``` + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ℹ 对象需要传输请求号 + + → 查询可用的传输请求... + 找到 2 个可修改的传输请求: + + 1. DEVK901362 Implement sales validation (所有者: ADMIN2) + 2. DEVK901365 Bugfix for invoice printing (所有者: ADMIN2) + 3. 新建传输请求 + 0. 不使用传输请求(本地对象) + + 请选择 [0-3]: 1 + ✓ 已选择传输请求: DEVK901362 + ✓ 锁定成功(传输请求: DEVK901362) + ✓ 源代码写入成功 + ✓ 解锁成功 +``` + + ── Step 1/4: 锁定 → 写入 → 解锁 ── + ✓ 锁定成功 + ✓ 源代码写入成功 + ✓ 解锁成功 + + ── Step 2/4: 语法检查 ── + ✓ 语法检查通过 + + ── Step 3/4: 激活对象 ── + ✓ 激活成功! + + ============================================================ + ✓ ZCL_MY_CLASS 已成功同步并激活! + ============================================================ +``` + +#### 输出示例 — 语法检查失败 + +``` + ── Step 2/4: 语法检查 ── + ✗ 语法检查未通过! 2 个错误, 1 个警告: + [错误] 行 10: Field "XXX" is unknown + [错误] 行 25: Type "ZZZ" is unknown + [警告] 行 5: Unused variable "LV_TEST" + + 请修改本地文件后重新执行: ./zmy_report.abap +``` + +> [!tip] 对象不存在时自动创建 +> 当同步的目标对象在 SAP 系统中不存在时,脚本会自动创建一个空对象,然后继续执行写入和激活流程。 +> 如果自动创建失败,会输出错误信息并退出,此时需要先使用 `create` 命令手动创建。 + +--- + +### 4.4 对象信息查询 (`info`) + +查询 SAP 系统中开发对象的元数据信息,包括描述、激活状态、开发包、传输请求号、负责人、修改时间等。 + +#### 执行流程 + +``` +登录 → 查询对象元数据 → 输出信息 +``` + +#### 命令格式 + +```bash +python main.py info --name <对象名> --type <类型> +``` + +#### 示例 + +```bash +# 查询类信息 +python main.py info --name ZCL_MY_CLASS --type class + +# 查询程序信息 +python main.py info --name ZMY_REPORT --type report + +# 查询接口信息 +python main.py info --name ZIF_MY_INTERFACE --type interface +``` + +#### 输出示例 + +``` +============================================================ + SAP ADT 对象信息查询 +============================================================ + 对象类型: 类(CLASS) + 对象名称: ZCL_MY_CLASS + + → 正在登录... + ✓ 登录成功 + + → 正在查询对象信息... + ✓ 查询成功 + + ────────────────────────────────────────────────── + 名称: ZCL_MY_CLASS + 类型: CLAS/OC + 描述: My ABAP Class + 状态: ✅ 已激活 + 负责人: ADMIN2 + 语言: ZH + 开发包: ZSAPILOT + 传输请求: DEVK901362 + 创建者: ADMIN2 + 修改者: ADMIN2 + 修改时间: 2026-05-20T12:00:00Z + URI: /sap/bc/adt/oo/classes/zcl_my_class + ────────────────────────────────────────────────── +``` + +> [!tip] 状态说明 +> - `✅ 已激活` — 对象处于 active 状态,可在系统中正常使用 +> - `⚠️ 未激活` — 对象有未激活的更改,需要执行 `sync` 激活后才能生效 + +> [!note] 开发包与传输请求 +> - **开发包** —— 直接从对象元数据读取 +> - **传输请求** —— 纯只读查询系统表 `E071`,显示对象当前所在的未释放请求(多个用逗号分隔) +> - `无` —— 对象不在任何未释放请求中 +> - `(无法获取)` —— 无系统表查询权限或查询失败(不影响其余字段输出) +> - `—(非独立传输对象)` —— `include` / `function` 等非独立 R3TR 对象,不单独占用传输请求 + +> [!note] 支持的对象类型 +> `info` 命令支持全部 9 种对象类型:`report`、`class`、`function`、`functiongroup`、`interface`、`domain`、`dataelement`、`table`、`tabletype`。 +> `tabletype` 通过 **VIT 端点**(`/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}`)访问,仅支持元数据查询(`info`),不支持源码读写(`download`/`sync`)和删除操作。 + +--- + +### 4.5 源代码删除 (`delete`) + +从 SAP 系统中删除指定的开发对象。**删除前需要交互确认**。 + +#### 执行流程 + +``` +登录 → 检查对象存在 → 确认删除 → 查询传输请求 → 锁定 → 执行删除 +``` + +#### 命令格式 + +```bash +python main.py delete --name <对象名> --type <类型> +``` + +#### 示例 + +```bash +# 删除 report +python main.py delete --name ZMY_OLD_REPORT --type report + +# 删除 class +python main.py delete --name ZCL_OBSOLETE --type class + +# 删除 function +python main.py delete --name ZGROUP/Z_OLD_FUNC --type function +``` + +#### 确认提示 + +``` + ⚠️ 即将从 SAP 系统中删除: ZMY_OLD_REPORT + 类型: 程序(REPORT) + URI: /sap/bc/adt/programs/programs/zmy_old_report + + 确认删除? (输入 yes 确认): +``` + +> [!danger] 不可逆操作 +> 删除操作不可恢复!请确认对象名称无误后再输入 `yes`。输入其他任何内容均取消操作。 + +## 5. API 端点映射 + +不同对象类型对应的 ADT REST API 路径: + +| 对象类型 | 对象 URI | 源代码 URI | +|---------|---------|-----------| +| `report` | `/sap/bc/adt/programs/programs/{name}` | `.../source/main` | +| `class` | `/sap/bc/adt/oo/classes/{name}` | `.../source/main` | +| `interface` | `/sap/bc/adt/oo/interfaces/{name}` | `.../source/main` | +| `function` | `/sap/bc/adt/functions/groups/{group}/fmodules/{name}` | `.../source/main` | +| `functiongroup` | `/sap/bc/adt/functions/groups/{name}` | — | +| `domain` | `/sap/bc/adt/ddic/domains/{name}` | `.../source/main` | +| `dataelement` | `/sap/bc/adt/ddic/dataelements/{name}` | `.../source/main` | +| `table` | `/sap/bc/adt/ddic/tables/{name}` | `.../source/main` | +| `tabletype` | `/sap/bc/adt/vit/wb/object_type/ttypda/object_name/{name}` | — (VIT 端点,仅元数据) | + +> [!note] URL 中的名称使用小写 +> SAP ADT API 的 URL 路径中对象名称通常使用**小写**形式。`tabletype` 例外 — VIT 端点使用**大写**名称以获取完整元数据。脚本已自动处理大小写转换,`--name` 参数输入原始大小写即可。 + +## 6. 典型工作流 + +### 场景一:创建新对象 → 修改 → 同步激活 + +```bash +# Step 1: 创建新程序(自动生成默认代码并激活) +python main.py create --name ZMY_REPORT --type report --description "我的报表" + +# Step 2: 下载到本地编辑 +python main.py download --name ZMY_REPORT --type report --path ./src + +# Step 3: 用编辑器修改 ./src/zmy_report.abap + +# Step 4: 同步激活 +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 场景二:下载 → 修改 → 同步激活 + +```bash +# Step 1: 下载已有对象源代码 +python main.py download --name ZMY_REPORT --type report --path ./src + +# Step 2: 用编辑器修改 ./src/zmy_report.abap + +# Step 3: 同步激活(如果激活失败,修改后重试) +python main.py sync --name ZMY_REPORT --type report --path ./src/zmy_report.abap +``` + +### 场景三:函数模块开发 + +```bash +# 创建函数组(如果不存在) +python main.py create --name ZMY_FGROUP --type functiongroup --description "我的函数组" + +# 创建函数模块(自动检测函数组,不存在则自动创建) +python main.py create --name ZMY_FGROUP/ZMY_FUNC --type function --description "我的函数" + +# 下载并修改 +python main.py download --name ZMY_FGROUP/ZMY_FUNC --type function --path ./src + +# 同步激活 +python main.py sync --name ZMY_FGROUP/ZMY_FUNC --type function --path ./src/zmy_func.abap +``` + +### 场景四:清理废弃对象 + +```bash +# 删除废弃程序 +python main.py delete --name ZOLD_REPORT --type report + +# 删除废弃类 +python main.py delete --name ZCL_OBSOLETE --type class + +# 删除废弃函数模块 +python main.py delete --name ZMY_FGROUP/ZOLD_FUNC --type function +``` + +## 7. 日志系统 + +所有 API 请求和响应详细记录在日志文件中: + +``` +sap-cli/adt_tools.log +``` + +### 日志内容示例 + +``` +2026-05-20 10:30:00 | INFO | LOGIN: GET http://.../compatibility/graph +2026-05-20 10:30:00 | INFO | LOGIN RESPONSE: HTTP 200 +2026-05-20 10:30:00 | INFO | CSRF Token: abCdEfGhIjKlMnOpQrSt... +2026-05-20 10:30:01 | INFO | GET SOURCE: GET .../source/main +2026-05-20 10:30:01 | INFO | GET SOURCE RESPONSE: HTTP 200, 37655 bytes +2026-05-20 10:30:02 | INFO | LOCK: POST .../programs/zmy_report (corrNr=DEVK901362) +2026-05-20 10:30:02 | INFO | LOCK RESPONSE: HTTP 200 +2026-05-20 10:30:03 | INFO | SET SOURCE: PUT .../source/main (1024 chars) +2026-05-20 10:30:03 | INFO | ACTIVATE: POST .../activation name=ZMY_REPORT +2026-05-20 10:30:03 | INFO | Activation msg [E] line=10: Field "XXX" unknown +2026-05-20 10:30:03 | INFO | Activation result: FAILED (1 errors) +``` + +### 排查问题 + +1. 查看 `adt_tools.log` 中的错误信息 +2. 搜索 `FAILED` 或 `ERROR` 关键字 +3. 激活错误中 `line` 为语句序号,`href` 中的 `start=XXX` 为字符偏移量 +4. 结合源代码定位问题位置 + +## 8. 常见问题 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 登录失败 HTTP 401 | 用户名或密码错误 | 检查 `config.ini` 中的连接配置 | +| 对象不存在 HTTP 404 | 名称或类型错误 | 确认 `--name` 和 `--type` 参数 | +| 对象已存在 HTTP 403 | 创建时对象名冲突 | 使用不存在的对象名或先删除已有对象 | +| 锁定失败 | 对象被其他用户锁定 | 脚本自动处理 corrNr 冲突;如仍失败,等待或联系管理员 | +| 未找到传输请求 | 系统中无 Modifiable 状态的 TR | 在 SAP 中创建传输请求 (SE01/SE09) | +| 激活报语法错误 | 代码有语法问题 | 修改本地文件后重新执行 `sync` 命令 | +| 删除失败 | 权限不足或对象被锁定 | 检查 S_DEVELOP 权限 | +| 环境变量覆盖配置 | 系统环境变量 SAP_HOST 已设置 | 运行前清除:`$env:SAP_HOST=""` | + +## 9. 相关笔记 + +- [[sap-cli/01.ADT架构与通信原理|01.ADT架构与通信原理]] — REST 通信层原理 +- [[sap-cli/03.修改代码-原理|03.修改代码-原理]] — 锁定/编辑/激活工作流 +- [[sap-cli/07.ADT代码同步工具|07.ADT代码同步工具]] — 早期版本的同步工具 +- [[sap-cli/doc/批量同步设计|批量同步设计]] — 项目清单驱动的批量同步方案设计 diff --git a/docs/功能矩阵分析.md b/docs/功能矩阵分析.md new file mode 100644 index 0000000..864d254 --- /dev/null +++ b/docs/功能矩阵分析.md @@ -0,0 +1,192 @@ +# sap-cli 功能矩阵分析报告 + +> 分析基于源码静态审查(`sapcli/types.py`、`sapcli/cli/parser.py`、`sapcli/commands/*`、`sapcli/client/*`)。 +> 生成时间:2026-06-18 + +--- + +## 第一部分:已实现的命令完整列表 + +共 **25 个顶层命令**(含子命令则为 **38 个操作**)。命令分发逻辑见 `sapcli/cli/app.py` 的 `command_map`(外加 `config`/`auth` 两个不走 SAP 连接的命令)。 + +| # | 命令 | 子命令 | 所在文件 | 功能描述 | 是否需 SAP 连接 | +|---|------|--------|----------|----------|----------------| +| 1 | `download` | — | `commands/crud.py::cmd_download` | 下载对象源代码到本地文件 | ✅ | +| 2 | `sync` | — | `commands/crud.py::cmd_sync` | 同步单对象源码并激活(检查→写入→语法检查→激活) | ✅ | +| 2b| `sync --all` | — | `commands/batch.py::cmd_sync_all` | 批量同步清单内所有对象(拓扑排序) | ✅ | +| 3 | `upload` | — | `commands/upload.py::cmd_upload` | 仅上传源码(锁定→写入→解锁,不检查不激活) | ✅ | +| 4 | `syntax-check` | — | `commands/syntax_check.py::cmd_syntax_check` | 远程对象语法检查(不上传不激活) | ✅ | +| 5 | `activate` | — | `commands/activate.py::cmd_activate` | 单独激活对象(单/批量模式) | ✅ | +| 6 | `delete` | — | `commands/crud.py::cmd_delete` | 从 SAP 删除对象(交互确认) | ✅ | +| 7 | `info` | — | `commands/crud.py::cmd_info` | 查询对象元数据 | ✅ | +| 8 | `create` | — | `commands/crud.py::cmd_create` | 创建开发对象(含 DDIC/函数组) | ✅ | +| 9 | `diff` | — | `commands/diff_cmd.py::cmd_diff` | 本地 vs SAP 代码差异对比 | ✅ | +| 10 | `list` | — | `commands/search.py::cmd_list` | 列出 SAP 对象(类型/包/前缀过滤) | ✅ | +| 11 | `whereused` | — | `commands/search.py::cmd_whereused` | Where-Used 引用查询 | ✅ | +| 12 | `search` | — | `commands/search.py::cmd_search` | 源代码搜索 | ✅ | +| 13 | `check` | — | `commands/quality.py::cmd_check` | ATC 代码检查 | ✅ | +| 14 | `format` | — | `commands/quality.py::cmd_format` | 代码格式化(Pretty Printer + 写回 + 激活) | ✅ | +| 15 | `transport` | `list` | `commands/transport.py::_transport_list` | 列出可修改的传输请求 | ✅ | +| 15b| | `info` | `::_transport_info` | 查看传输请求详情 | ✅ | +| 15c| | `release` | `::_transport_release` | 释放传输请求(交互确认) | ✅ | +| 15d| | `objects` | `::_transport_objects` | 列出传输请求中对象 | ✅ | +| 16 | `package` | `create` | `commands/package_cmd.py::_package_create` | 创建 ABAP 包 | ✅ | +| 16b| | `info` | `::_package_info` | 查看包详情 | ✅ | +| 16c| | `list` | `::_package_list` | 列出包中对象 | ✅ | +| 17 | `cds` | `download` | `commands/cds.py::_cds_download` | 下载 CDS View DDL 源码 | ✅ | +| 17b| | `sync` | `::_cds_sync` | 同步本地 DDL 到 SAP(含创建/激活) | ✅ | +| 17c| | `create` | `::_cds_create` | 创建新的 CDS View | ✅ | +| 18 | `init` | — | `commands/batch.py::cmd_init` | 扫描本地→查询 SAP→生成 manifest.json | ✅ | +| 19 | `refresh` | — | `commands/batch.py::cmd_refresh` | 刷新清单中对象状态 | ✅ | +| 20 | `analyze` | — | `commands/analyze.py::cmd_analyze` | 本地依赖自动分析(正则匹配) | ✅ | +| 21 | `scaffold` | — | `commands/scaffold.py::cmd_scaffold` | 项目模板创建(4 种模板) | ❌(本地) | +| 22 | `config` | `show`/`list-profiles`/`set` | `commands/config_cmd.py::cmd_config` | 配置管理 | ❌ | +| 23 | `auth` | `login`/`logout`/`status` | `auth.py::cmd_auth_*` | keyring 密钥管理 | ❌ | +| 24 | `show-table` | — | `commands/ddl_query.py::cmd_show_table` | 查看 DDIC 表字段结构(SE11) | ✅ | +| 25 | `read-table` | — | `commands/ddl_query.py::cmd_read_table` | 查询表数据(freestyle SQL) | ✅ | +| 26 | `run-program` | — | `commands/program_run.py::cmd_run_program` | 远程执行 ABAP 程序(SA38) | ✅ | + +--- + +## 第二部分:已注册的对象类型完整列表 + +共 **16 个对象类型**,全部在 `sapcli/types.py` 中通过 `_register()` 注册。 + +| # | key | label | 对象端点 (obj_uri) | 源码端点 (src_uri) | has_source | create_content_type | +|---|-----|-------|-------------------|-------------------|-----------|---------------------| +| 1 | `report` | 程序(REPORT) | `/programs/programs/{name}` | `/programs/programs/{name}/source/main` | ✅ | `vnd.sap.adt.programs.programs.v2+xml` | +| 2 | `class` | 类(CLASS) | `/oo/classes/{name}` | `/oo/classes/{name}/source/main` | ✅ | `vnd.sap.adt.oo.classes.v1+xml` | +| 3 | `function` | 函数模块 | `/functions/groups/{group}/fmodules/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.functions.fmodules.v1+xml` | +| 4 | `functiongroup` | 函数组 | `/functions/groups/{name}` | — (None) | ❌ | `vnd.sap.adt.functions.groups.v2+xml` | +| 5 | `interface` | 接口 | `/oo/interfaces/{name}` | `/oo/interfaces/{name}/source/main` | ✅ | `vnd.sap.adt.oo.interfaces.v1+xml` | +| 6 | `domain` | 域(DOMAIN) | `/ddic/domains/{name}` | `/ddic/domains/{name}/source/main` | ✅ | `vnd.sap.adt.domains.v2+xml` | +| 7 | `dataelement` | 数据元素 | `/ddic/dataelements/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.dataelements.v2+xml` | +| 8 | `table` | 透明表 | `/ddic/tables/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.tables.v1+xml` | +| 9 | `structure` | 结构 | `/ddic/structures/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.structures.v1+xml` | +| 10 | `tabletype` | 表类型 | `/vit/.../object_name/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.tabletypes.v1+xml` | +| 11 | `include` | Include程序 | `/programs/includes/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.programs.includes.v2+xml` | +| 12 | `cdsview` | CDS视图 | `/dds/ddl/sources/{name}` | `/dds/ddl/sources/{name}/content` | ✅ | `text/plain` | +| 13 | `messageclass` | 消息类 | `/oo/t100/messages/classes/{name}` | — (None) | ❌ | `vnd.sap.adt.t100.message.classes.v1+xml` | +| 14 | `view` | 数据库视图 | `/ddic/views/{name}` | `.../source/main` | ✅ | `vnd.sap.adt.ddic.views.v1+xml` | +| 15 | `searchhelp` | 搜索帮助 | `/ddic/searchhelps/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.searchhelps.v1+xml` | +| 16 | `lockobject` | 锁对象 | `/ddic/lockobjects/{name}` | — (None) | ❌ | `vnd.sap.adt.ddic.lockobjects.v1+xml` | + +**关键判定规则**:`has_source=False` 的 5 种类型(functiongroup / tabletype / messageclass / searchhelp / lockobject)**无法**执行源码类操作(download / sync / upload / syntax-check / format / diff),命令会在 `parse_object_name` 后因 `src_uri is None` 抛 `InvalidNameError`。 + +### client.py 中各操作的 HTTP 实现 + +| 操作 | client 方法 | HTTP 调用 | 所在 Mixin | +|------|------------|----------|-----------| +| 读源码 | `get_source` | `GET {src_uri}` (text/plain) | `_source.py` | +| 写源码 | `set_source` | `PUT {src_uri}` (含 corrNr 重试) | `_source.py` | +| 锁定 | `lock` | `POST {obj_uri}?_action=LOCK` | `_source.py` | +| 解锁 | `unlock` | `POST {obj_uri}?_action=UNLOCK` | `_source.py` | +| 激活 | `activate` | `POST /adt/activation?method=activate` | `_source.py` | +| 语法检查 | `syntax_check` | `POST /adt/activation?method=check` | `_source.py` | +| 删除 | `delete_object` | `DELETE {obj_uri}` (先锁) | `_ddic.py` | +| 创建(通用) | `create_object` | `POST {collection_uri}` | `_ddic.py` | +| 创建(DDIC) | `create_ddic_object` | `POST`/`PUT {src_uri}` (DDL) | `_ddic.py` | +| 状态查询 | `get_object_status` | `GET {obj_uri}` (+ lock 探测 corrNr) | `_ddic.py` | +| 存在检查 | `object_exists` | `GET {obj_uri}` (200/404) | `_base.py` | +| 函数组创建 | `create_function_group` | `POST {collection}` | `_ddic.py` | +| CDS 源码 | `get_cds_source`/`create_cds` | `GET/POST /ddic/ddlsources/*` | `_ddic.py` | +| 对象列表 | `list_objects` | `GET /repository/informationsystem/search` | `_search.py` | +| 引用查询 | `where_used` | `POST /usage/whereusedlist` | `_search.py` | +| 代码搜索 | `search_code` | `POST /repository/structuredsearch` | `_search.py` | +| ATC 检查 | `atc_check` | `POST /atos/checks` | `_ddic.py` | +| 格式化 | `pretty_print` | `POST /prettyprinter` | `_ddic.py` | +| 表结构 | `get_table_fields` | `GET /datapreview/ddic/{name}/metadata` | `_ddic.py` | +| 表数据 | `query_table_data` | `POST /datapreview/freestyle` | `_ddic.py` | +| 运行程序 | `run_program` | `POST /programs/programrun/{name}` | `_ddic.py` | + +--- + +## 第三部分:半实现 / 受限功能列表 + +### 🔴 关键限制(会导致功能不可用) + +1. **`create` 通用命令不支持 6 种类型** — `client/_ddic.py::_build_create_body` 仅覆盖 9 种类型,对其余直接 `raise ValueError("不支持的对象类型")`: + - ❌ `include`、`cdsview`、`messageclass`、`view`、`searchhelp`、`lockobject` + - 其中 `cdsview` 有独立的 `cds create` 命令可绕过;其余 5 种**完全无法创建**。 + +2. **CDS View 端点不一致(潜在 Bug)** — 两套 URI 并存: + - `types.py` 注册的 cdsview:`/sap/bc/adt/dds/ddl/sources/{name}` + `/content` + - `cds.py` 命令 & `client._ddic.py` 硬编码:`/sap/bc/adt/ddic/ddlsources/{name}` + `/source/main` + - 后果:`download --type cdsview` 走 types.py 的 URI,可能 404;而 `cds download` 走另一套 URI。 + +3. **`tabletype` 创建必须传 `--definition`** — `_create_ddic` 对 tabletype 无默认模板,缺 `line_type` 时直接 `raise CreateError`。 + +### 🟡 功能降级(有 fallback 但不完整) + +4. **`info` 命令 Accept 头映射不全** — `cmd_info` 的 `INFO_ACCEPT` 字典只覆盖 9 种类型,以下 7 种回退到 `application/xml`(可能触发 406 后再回退): + - ⚠️ `structure`、`include`、`cdsview`、`messageclass`、`view`、`searchhelp`、`lockobject` + +5. **`list`/`whereused`/`search` 的 ADT 类型码映射不全** — `search.py::_TYPE_FILTER_MAP` 缺 2 种: + - ⚠️ `searchhelp`、`lockobject`(会原样传 key 当类型码,ADT 端可能不识别) + +6. **NW 7.40 激活空响应** — `client/_source.py::activate` 注释标注:NW 7.40 返回空响应时已实现「双重激活」变通,但仍可能提示「对象仍 inactive,请在 SE09 手动激活」(返回 W 级消息)。 + +7. **`tabletype` 用 VIT 端点** — `parse_object_name` 对 tabletype 做大写处理,注释说明「VIT 端点需要大写名称以返回完整元数据」,暗示该端点行为特殊。 + +### 🟢 设计限制(非 Bug) + +8. **`functiongroup` 是容器对象** — `has_source=False`,download/sync/upload/format 等源码操作明确拒绝(提示「不包含可编辑的源代码文件」)。 + +9. **scaffold 模板中的 `TODO`** — `scaffold.py` 模板字符串内的 `\" TODO: 填充数据` / `\" TODO: 调用 BAPI 函数` 是**生成的代码占位符**,非 CLI 自身 TODO。 + +10. **DDIC 源码下载语义** — domain/dataelement/table/structure 虽有 `src_uri`,但其 `source/main` 返回的是 DDL/XML 定义而非可编辑 ABAP 文本,download 技术可用但语义需注意。 + +### 未发现的问题 +- ✅ 未发现 `NotImplementedError`、`pass` 占位方法、或裸 `...` 存根。 +- ✅ 所有 parser 注册的命令在 `app.py` 均有对应 handler(无悬空命令)。 + +--- + +## 第四部分:命令 × 类型 完整交叉矩阵 + +**图例**:✅ 完全支持 | ⚠️ 部分支持/有降级 | ❌ 不支持 | ➖ 不适用(命令与对象类型无关) + +> 仅列出「带 `--type` 参数、面向单个对象类型」的 14 个命令。`init`/`refresh`/`sync --all`/`analyze`/`config`/`auth`/`transport`/`package`/`scaffold`/`show-table`/`read-table`/`run-program`/`cds` 为项目级或领域专属命令,对所有类型行为一致,不纳入交叉矩阵。 + +| 命令 \ 类型 | report | class | function | functiongroup | interface | domain | dataelement | table | structure | tabletype | include | cdsview | messageclass | view | searchhelp | lockobject | +|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| **download** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **sync** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **upload** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **syntax-check** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **format** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **diff** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅¹ | ❌ | ✅ | ❌ | ❌ | +| **info** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | ⚠️² | +| **delete** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **activate** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **create** | ✅ | ✅ | ✅³ | ✅³ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁴ | ❌⁵ | ⚠️⁶ | ❌⁵ | ❌⁵ | ❌⁵ | ❌⁵ | +| **check (ATC)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **whereused** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | +| **list** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | +| **search** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️⁷ | ⚠️⁷ | + +**脚注**: +- ¹ **cdsview 经通用源码命令**:走 `types.py` 的 `/dds/ddl/sources/{name}/content` 端点,与专用 `cds` 命令的 `/ddic/ddlsources/{name}/source/main` 不一致,可能失败(见第三部分#2)。 +- ² **info 降级**:无显式 Accept 头,回退 `application/xml`(见第三部分#4)。 +- ³ **function/functiongroup 特殊创建**:function 自动创建函数组;functiongroup 走 `create_function_group` 专用路径。 +- ⁴ **tabletype 创建受限**:必须 `--definition` 指定 line_type,无默认模板(见第三部分#3)。 +- ⁵ **create 不支持**:`_build_create_body` 直接 `ValueError`(见第三部分#1)。`messageclass`/`view`/`searchhelp`/`lockobject`/`include` 无任何创建路径。 +- ⁶ **cdsview 经通用 create 失败**:但可用专用 `cds create` 命令。 +- ⁷ **ADT 类型码缺失**:`_TYPE_FILTER_MAP` 无 searchhelp/lockobject 映射,过滤参数原样透传(见第三部分#5)。 + +### 支持度统计 + +| 命令 | ✅ 完全 | ⚠️ 部分 | ❌ 不支持 | +|:---|:---:|:---:|:---:| +| download/sync/upload/syntax-check/format/diff | 11 | 0 | 5 | +| info | 9 | 7 | 0 | +| delete | 16 | 0 | 0 | +| activate / check | 16 | 0 | 0 | +| create | 9 | 2 | 5 | +| whereused / list / search | 14 | 2 | 0 | + +**结论**:`delete`/`activate`/`check` 三命令对全部 16 种类型通用;源码类 6 命令对 11 种 has_source 类型通用;**`create` 是缺口最大的命令**(6 种类型无法创建)。 + +--- + +*报告结束。如需复查某条判定,可按文中引用的文件路径与行号定位源码。* diff --git a/docs/差距分析报告.md b/docs/差距分析报告.md new file mode 100644 index 0000000..17b9bf7 --- /dev/null +++ b/docs/差距分析报告.md @@ -0,0 +1,202 @@ +# sap-cli 差距分析报告:ABAP 开发对象 × 已实现功能 + +> **项目**: sap-cli — 用你喜欢的编辑器写 ABAP +> **当前版本**: v2.1.0 | 25 命令 | 16 对象类型 +> **编制日期**: 2026-06-17 +> **目的**: 识别遗漏功能,为下一轮大优化提供决策依据 + +--- + +## 一、ABAP 开发对象完整全景 + +SAP 系统中可开发的 ABAP 对象**远不止 16 种**。按照 SAP Repository 结构,完整分类如下: + +### 1.1 已支持(16 种) + +| 类别 | 类型 | 覆盖率 | +|------|------|--------| +| 程序 | report · include · functiongroup | 3/5 | +| 面向对象 | class · interface | 2/2 | +| 函数 | function | 1/1 | +| DDIC 基础 | domain · dataelement · table · structure · tabletype | 5/8 | +| DDIC 扩展 | cdsview · view · messageclass · searchhelp · lockobject | 5/12 | + +### 1.2 未支持但 ADT 有端点(优先级最高) + +| 优先级 | 类型 | ADT 端点 | 使用场景 | +|:------:|------|---------|---------| +| ⭐⭐⭐ | **CDS Access Control (DCL)** | `/sap/bc/adt/ddic/dclsources` | CDS 权限控制,与 CDS View 配套 | +| ⭐⭐⭐ | **Behavior Definition (BDEF)** | `/sap/bc/adt/ddic_behaviordefinitions` | RAP(ABAP RESTful Application Programming Model)核心 | +| ⭐⭐⭐ | **Metadata Extension (DDLX)** | `/sap/bc/adt/ddic/metadataextensions` | CDS View 元数据扩展(UI 注解) | +| ⭐⭐⭐ | **Type Group (TYPE-POOL)** | `/sap/bc/adt/programs/typegroups` | 常量/类型集中定义,大量老项目仍在用 | +| ⭐⭐ | **AMDP 类** | 复用 class 端点 | HANA 数据库存储过程(标记接口 IF_AMDP_MARKER) | +| ⭐⭐ | **SICF Service** | `/sap/bc/adt/discovery/services` | HTTP Handler,API 开发必备 | +| ⭐⭐ | **Enhancement Spot** | `/sap/bc/adt/enhancements` | BAdI 定义 + 增强,Clean Core 依赖 | +| ⭐⭐ | **BAdI Implementation** | 复用 class/enhancement 端点 | 标准对象增强,实施项目高频 | +| ⭐⭐ | **Number Range Object** | `/sap/bc/adt/ddic/nrranges` | 编号范围,每个业务模块都用 | +| ⭐ | **XSLT Program** | `/sap/bc/adt/transformations` | XML 转换,接口开发场景 | +| ⭐ | **Web Dynpro** | 专用端点 | 传统 Web UI(逐渐淘汰但存量巨大) | +| ⭐ | **Gateway Service (SEGW)** | `/sap/bc/adt/sadl/...` | OData 服务定义 | + +### 1.3 未支持且 ADT 无标准端点(SAP GUI 独占) + +| 类型 | SE80 类别 | 实现路径 | +|------|----------|---------| +| Screen / Dynpro | Dialog Programming | 需 RFC 或 GUI Scripting | +| GUI Status / Title | Dialog Programming | 需 RFC | +| Transaction Code | Dictionary | 需 RFC(无 ADT 端点) | +| Report Variant | Programs | 需 RFC(RS_VARIANT_CONTENTS) | +| SAPScript Form | Forms | 需 RFC | +| SmartForm | Forms | 需 RFC(无 ADT 端点) | +| Adobe Form (SFP) | Forms | 需 RFC | +| Workflow Template | Business Obj | 需 RFC | +| Business Object (BOR) | Business Obj | 需 RFC | +| Append Structure | DDIC | ADT 可能间接支持 | + +--- + +## 二、已实现功能 × 对象类型 完整矩阵 + +> 图例:✅ 完全支持 | ⚠️ 部分支持/有 bug | ❌ 不支持 | ➖ 不适用(容器对象) + +| 操作 \ 类型 | report | class | func | funcgrp | intf | domain | datael | table | struct | tbltype | include | cdsview | msgcls | view | srchhelp | lockobj | +|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| **create** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | +| **download** | ✅ | ✅ | ✅ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | ✅ | ⚠️ | ➖ | ✅ | ➖ | ➖ | +| **sync** | ✅ | ✅ | ✅ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | ✅ | ⚠️ | ➖ | ✅ | ➖ | ➖ | +| **delete** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **activate** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **info** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | + +**关键结论**:`create` 是最大缺口 — **6 种已注册类型完全无法通过通用命令创建**。 + +--- + +## 三、已实现命令全景(25 个顶层命令) + +| 类别 | 命令 | 状态 | +|------|------|------| +| **对象 CRUD** | create · download · sync · upload · delete · info · activate | ✅ | +| **代码质量** | check(ATC) · format(PrettyPrinter) · diff · syntax-check | ✅ | +| **搜索浏览** | list · whereused · search · show-table · read-table | ✅ | +| **批量管理** | init · refresh · sync --all · analyze · scaffold | ✅ | +| **传输/包** | transport(list/info/release/objects) · package(create/info/list) | ✅ | +| **CDS** | cds(download/sync/create) | ✅ | +| **远程执行** | run-program | ✅ | +| **配置/认证** | config(show/set/list-profiles) · auth(login/logout/status) | ✅ | + +--- + +## 四、已发现的 Bug 和缺陷 + +### 🔴 P0 — 功能性 Bug + +| # | 问题 | 影响 | 位置 | +|---|------|------|------| +| 1 | **CDS View 端点不一致** | `download --type cdsview` 可能 404 | types.py 用 `/dds/ddl/sources/`,cds.py 用 `/ddic/ddlsources/` | +| 2 | **create 不支持 6 种类型** | include/messageclass/view/searchhelp/lockobject 无法创建 | `client/_ddic.py::_build_create_body` 直接 ValueError | + +### 🟡 P1 — 功能降级 + +| # | 问题 | 影响 | 位置 | +|---|------|------|------| +| 3 | **info 的 Accept 头映射缺 7 种类型** | 7 种类型查询可能返回 406 或数据不完整 | `commands/crud.py::INFO_ACCEPT` | +| 4 | **list/whereused/search 的类型码缺 2 种** | searchhelp/lockobject 过滤可能不生效 | `commands/search.py::_TYPE_FILTER_MAP` | +| 5 | **tabletype 创建必须传 --definition** | 无法用默认模板快速创建 | `client/_ddic.py::_create_ddic` | + +### 🟢 P2 — 设计限制(可接受) + +| # | 问题 | 说明 | +|---|------|------| +| 6 | functiongroup/tabletype/messageclass/searchhelp/lockobject 无源码操作 | has_source=False,设计如此 | +| 7 | NW 7.40 激活空响应 | 已有 double-activate 变通,失败提示去 SE09 | +| 8 | DDIC 源码下载返回 XML/DDL 而非 ABAP | 语义差异,非 bug | + +--- + +## 五、未实现的功能命令 + +| 优先级 | 命令 | 功能 | 实现难度 | 价值 | +|:------:|------|------|:--------:|:----:| +| ⭐⭐⭐ | `clone` | 从一个系统下载 → 上传到另一个系统(跨系统迁移) | 中 | 极高 | +| ⭐⭐⭐ | `history` | 查看 SAP 端版本历史 + 版本对比 | 中 | 高 | +| ⭐⭐⭐ | `enhancement` | Enhancement Spot / BAdI 增强管理 | 高 | 高 | +| ⭐⭐⭐ | `number-range` | 编号范围管理(创建/查看/调整) | 中 | 高 | +| ⭐⭐ | `where-used --reverse` | 反向 Where-Used(谁引用了我) | 低 | 中 | +| ⭐⭐ | `unit-test` | 触发远程 ABAP Unit 测试 | 中 | 高 | +| ⭐⭐ | `variant` | 报表变体管理(创建/复制/传输) | 中 | 中 | +| ⭐⭐ | `tcode` | 事务码管理(创建/分配) | 中 | 中 | +| ⭐⭐ | `aunit` | ABAP Unit 测试执行和结果查看 | 中 | 高 | +| ⭐ | `bapi` | BAPI 浏览和调用(测试用) | 低 | 中 | +| ⭐ | `stms` | 传输系统管理(STMS 等价) | 高 | 中 | +| ⭐ | `debug` | 远程调试辅助(设置断点、读取 dump) | 高 | 中 | +| ⭐ | `dump` | ST22 Dump 分析(读取/搜索/过滤) | 中 | 中 | + +--- + +## 六、优化优先级建议 + +### Phase 1 — 修复 Bug + 补齐缺口(v2.2) + +| 任务 | 预估工时 | 影响 | +|------|---------|------| +| 修复 CDS View 端点不一致 | 2h | 消除 P0 Bug | +| 补齐 create 对 5 种类型的支持 | 4h | include/messageclass/view/searchhelp/lockobject | +| 补齐 info Accept 头映射 | 1h | 消除 P1 降级 | +| 补齐 search 类型码映射 | 0.5h | 消除 P1 降级 | + +### Phase 2 — 扩展对象类型(v3.0) + +| 任务 | 预估工时 | 价值 | +|------|---------|------| +| Type Group 支持 | 2h | 老项目兼容 | +| Number Range Object 支持 | 3h | 业务模块必需 | +| Enhancement Spot / BAdI 支持 | 8h | Clean Core 实施必备 | +| CDS Access Control (DCL) 支持 | 4h | RAP 开发配套 | +| Behavior Definition (BDEF) 支持 | 6h | RAP 核心 | +| SICF Service 支持 | 4h | API 开发 | + +### Phase 3 — 新增命令(v3.1+) + +| 任务 | 预估工时 | 价值 | +|------|---------|------| +| `clone`(跨系统迁移) | 4h | 多系统项目效率 | +| `history`(版本管理) | 6h | 代码审计 | +| `unit-test` / `aunit` | 8h | 测试自动化 | +| `variant` | 4h | 变体管理 | +| `dump`(ST22 分析) | 4h | 异常排查 | + +--- + +## 七、总结 + +### 当前覆盖度 + +``` +ABAP 开发对象全谱系: + 已支持:16 种 + ADT 可支持但未实现:~12 种(高价值) + SAP GUI 独占:~10 种(需 RFC) + 总覆盖率:约 42%(已支持 / ADT 可支持范围) +``` + +### 核心矛盾 + +> 设计理念是「用你喜欢的编辑器写 ABAP」,但目前的 16 种对象类型主要覆盖了**传统 ABAP 开发场景**(程序 + DDIC)。 + +**现代 ABAP 开发场景(S/4HANA / RAP / API)覆盖不足**: +- ❌ CDS 权限控制(DCL)— RAP 配套缺失 +- ❌ Behavior Definition(BDEF)— RAP 核心缺失 +- ❌ Enhancement Spot / BAdI — Clean Core 实施核心缺失 +- ❌ SICF Service — API 开发缺失 + +### 建议的演进方向 + +``` +Phase 1 (v2.2): 修复 Bug + 补齐 create 缺口 ← 2 天 +Phase 2 (v3.0): 扩展对象类型(RAP + Enhancement) ← 1-2 周 +Phase 3 (v3.1): 新增命令(clone/history/unit-test)← 1 周 +Phase 4 (v4.0): MCP 服务器模式(AI 集成) ← 未来 +``` + +从「能用的工具」到「不可替代的开发平台」,下一步的关键是**覆盖现代 ABAP 开发场景**。 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml b/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml new file mode 100644 index 0000000..d159b7d --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: add-dictionary-objects +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md new file mode 100644 index 0000000..4c6bcf6 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/design.md @@ -0,0 +1,32 @@ +## Context + +sap-cli 当前支持 10 种 ABAP 开发对象类型的 CRUD 操作。本变更扩展至 14 种类型,新增消息类、数据库视图、搜索帮助和锁对象。这 4 种类型都属于 ABAP Dictionary (DDIC) 类别,创建时需要 XML body 而非纯文本源码。 + +## Goals + +- 注册 4 种新的 DDIC 对象类型到类型注册表 +- 实现 4 种类型的 create/info/delete 操作 +- 对有源码的类型(view)支持 download/sync +- 对无源码的类型(messageclass、lockobject)明确提示不支持 +- 支持 JSON 定义文件创建 DDIC 对象 + +## Non-Goals + +- 不实现搜索帮助的测试数据生成 +- 不实现锁对象的自动函数模块调用 +- 不涉及 CDS Access Control (DCL) 类型(P4 级别) + +## Decisions + +1. **消息类使用专用端点**:`/sap/bc/adt/messageclasses`,消息内容通过 XML 维护 +2. **视图使用 DDIC 端点**:`/sap/bc/adt/ddic/views`,与 table/structure 类似的 XML schema +3. **搜索帮助使用 DDIC 端点**:`/sap/bc/adt/ddic/searchhelps` +4. **锁对象使用 DDIC 端点**:`/sap/bc/adt/ddic/lockobjects` +5. **文件扩展名统一 .abap**:有源码类型(view)的源码文件保持 .abap 扩展名 +6. **目录命名约定**:`messageclasses/`、`views/`、`searchhelps/`、`lockobjects/` + +## Risks / Trade-offs + +- **ADT 端点兼容性**:不同 SAP 版本(ECC vs S/4HANA)的端点可能有差异,需要实际验证 +- **消息类 XML schema 复杂**:消息内容可能需要额外的 XML 处理逻辑 +- **锁对象自动生成物**:SAP 会在锁对象创建时自动生成 enqueue/dequeue 函数模块,删除锁对象时这些也会被删除 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md new file mode 100644 index 0000000..85e035b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/proposal.md @@ -0,0 +1,30 @@ +# Proposal: 新增 ABAP 字典对象类型支持 + +## Why + +当前 sap-cli 支持的 10 种对象类型覆盖了日常开发的基础需求,但 SAP 实际项目中还频繁使用消息类(Message Class)、数据库视图(Database View)、搜索帮助(Search Help)和锁对象(Lock Object)。缺少这些类型意味着开发者仍需回到 SAP GUI 处理这些对象,破坏了 CLI 工具的连贯工作流。 + +## What Changes + +- **消息类 (Message Class)**:MESSAGE 语句的核心依赖,几乎每个项目都需要。ADT 端点 `/sap/bc/adt/messageclasses` +- **数据库视图 (Database View)**:数据建模和查询优化的基础工具。ADT 端点 `/sap/bc/adt/ddic/views` +- **搜索帮助 (Search Help)**:ALV 屏幕 F4 帮助和数据输入辅助。ADT 端点 `/sap/bc/adt/ddic/searchhelps` +- **锁对象 (Lock Object)**:多用户并发控制,数据一致性保障。ADT 端点 `/sap/bc/adt/ddic/lockobjects` + +这 4 种类型均为 DDIC 类别,创建时需要 XML 定义(非纯文本源码),与现有的 domain/dataelement/table 类型处理方式类似。 + +## Capabilities + +### New Capabilities +(无新领域) + +### Modified Capabilities +- `object-lifecycle`: 扩展支持消息类、数据库视图、搜索帮助、锁对象的 CRUD 操作 + +## Impact + +- types.py:注册 4 个新 ObjectTypeConfig +- client.py:新增 4 种类型的创建模板和 ADT 交互逻辑 +- ddic.py:扩展 DDIC XML 生成以支持新类型 +- scanner.py:新增目录映射(messageclasses/、views/、searchhelps/、lockobjects/) +- 约定文件扩展名保持 .abap(消息类和锁对象无源码端点,仅支持 create/info/delete) diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..37a730c --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/specs/object-lifecycle/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 14 种对象类型。 + +#### Scenario: Create message class +- **WHEN** 用户执行 create 命令,指定类型为 messageclass,并提供名称和描述 +- **THEN** 系统在 SAP 中创建消息类对象 + +#### Scenario: Create database view +- **WHEN** 用户执行 create 命令,指定类型为 view,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建数据库视图 + +#### Scenario: Create search help +- **WHEN** 用户执行 create 命令,指定类型为 searchhelp,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建搜索帮助 + +#### Scenario: Create lock object +- **WHEN** 用户执行 create 命令,指定类型为 lockobject,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建锁对象 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码,支持所有有源码端点的对象类型。 + +#### Scenario: Download database view source +- **WHEN** 用户下载 view 类型对象 +- **THEN** 系统下载视图的 DDL 定义源码 + +#### Scenario: Message class has no source +- **WHEN** 用户尝试下载 messageclass 类型 +- **THEN** 系统报告该类型不支持下载操作 + +#### Scenario: Lock object has no source +- **WHEN** 用户尝试下载 lockobject 类型 +- **THEN** 系统报告该类型不支持下载操作 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中所有支持类型的开发对象元数据。 + +#### Scenario: Query message class info +- **WHEN** 用户执行 info 命令查询消息类 +- **THEN** 系统显示消息类的名称、描述、消息数量等元数据 + +#### Scenario: Query view info +- **WHEN** 用户执行 info 命令查询数据库视图 +- **THEN** 系统显示视图的名称、描述、关联表等元数据 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除所有支持类型的开发对象。 + +#### Scenario: Delete message class +- **WHEN** 用户执行 delete 命令删除消息类并确认 +- **THEN** 系统删除该消息类 + +#### Scenario: Delete lock object +- **WHEN** 用户执行 delete 命令删除锁对象并确认 +- **THEN** 系统删除该锁对象及其自动生成的函数模块 diff --git a/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md b/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md new file mode 100644 index 0000000..7659654 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-add-dictionary-objects/tasks.md @@ -0,0 +1,34 @@ +## 1. Type Registration + +- [x] 1.1 Register messageclass in types.py with ADT URI template +- [x] 1.2 Register view in types.py with ADT URI template +- [x] 1.3 Register searchhelp in types.py with ADT URI template +- [x] 1.4 Register lockobject in types.py with ADT URI template +- [x] 1.5 Verify all_type_keys() returns 16 types + +## 2. DDIC XML Generation + +- [x] 2.1 Add messageclass XML template to ddic.py +- [x] 2.2 Add view XML template to ddic.py +- [x] 2.3 Add searchhelp XML template to ddic.py +- [x] 2.4 Add lockobject XML template to ddic.py + +## 3. Client API Extension + +- [x] 3.1 Add messageclass create body to _build_create_body +- [x] 3.2 Add view create body to _build_create_body +- [x] 3.3 Add searchhelp create body to _build_create_body +- [x] 3.4 Add lockobject create body to _build_create_body + +## 4. Scanner Update + +- [x] 4.1 Add messageclasses/ directory mapping to scanner.py +- [x] 4.2 Add views/ directory mapping to scanner.py +- [x] 4.3 Add searchhelps/ directory mapping to scanner.py +- [x] 4.4 Add lockobjects/ directory mapping to scanner.py + +## 5. Verification + +- [x] 5.1 Run existing test suite (66/68 baseline preserved) +- [x] 5.2 Updated test_openspec_specs_exist for new 7-domain structure +- [x] 5.3 Verified --help and all imports work diff --git a/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml b/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml new file mode 100644 index 0000000..7353056 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: base-batch +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-base-batch/proposal.md b/openspec/changes/archive/2026-06-09-base-batch/proposal.md new file mode 100644 index 0000000..a333bf0 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/proposal.md @@ -0,0 +1,27 @@ +# Proposal: 批量操作与项目管理 + +## Why + +单对象 CRUD 无法满足实际项目中的批量化需求。SAP 项目通常包含数十甚至上百个相互依赖的开发对象,需要批量初始化、状态跟踪、依赖排序和批量同步的能力。同时,传输请求管理是 SAP 开发工作流中不可或缺的一环。 + +## What Changes + +- 项目初始化:创建约定目录结构(reports/、classes/、functions/ 等)和 manifest.json +- 目录扫描:自动识别 .abap 文件,按约定目录映射对象类型 +- 清单管理:manifest.json 跟踪对象状态、同步结果和依赖关系 +- 依赖排序:基于 Kahn 算法进行拓扑排序,确保正确的执行顺序 +- 批量同步:sync --all 模式,支持 --dry-run 和 --fail-fast +- 状态刷新:批量检查 SAP 系统中的对象状态 +- 传输请求基础管理:list、create、select + +## Capabilities + +### New Capabilities +- `batch-ops`: 项目级批量操作(初始化、清单管理、扫描、排序、批量同步、状态刷新) + +## Impact + +- 引入 manifest.json 作为项目状态跟踪机制 +- 建立约定目录结构(reports/、classes/、interfaces/、functions/ 等 10 个目录) +- sync 命令新增 --all、--dry-run、--fail-fast 参数 +- 为后续 P0/P1 扩展(list、whereused、transport 高级管理等)奠定基础 diff --git a/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md b/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md new file mode 100644 index 0000000..908cee5 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/specs/batch-ops/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Project Initialization + +系统 SHALL 初始化一个 ABAP 项目目录结构。 + +#### Scenario: Init new project +- **WHEN** 用户执行 init 命令并指定项目路径 +- **THEN** 系统创建标准目录结构(reports/、classes/、functions/ 等)和空的 manifest.json + +### Requirement: Manifest Management + +系统 SHALL 管理项目清单文件,记录所有开发对象的状态。 + +#### Scenario: Manifest tracks objects +- **WHEN** 对象被创建、同步或删除后 +- **THEN** 清单文件自动更新对象的状态、最后同步时间和同步结果 + +#### Scenario: Query pending objects +- **WHEN** 清单中存在未成功同步的对象 +- **THEN** 系统能够识别并列出这些待处理对象 + +#### Scenario: Query up-to-date objects +- **WHEN** 清单中存在已激活且同步成功的对象 +- **THEN** 系统能够过滤出这些就绪对象 + +### Requirement: Directory Scanning + +系统 SHALL 扫描本地项目目录,自动识别 ABAP 开发对象文件。 + +#### Scenario: Scan standard directories +- **WHEN** 项目目录包含约定命名的子目录及其中的 .abap 文件 +- **THEN** 系统识别每个文件对应的对象名称、类型和路径 + +#### Scenario: Function directory structure +- **WHEN** 扫描 functions/ 目录 +- **THEN** 子目录名解析为函数组名,文件名解析为函数模块名 + +#### Scenario: Skip hidden files +- **WHEN** 目录中存在以 . 开头的文件 +- **THEN** 系统跳过这些隐藏文件 + +### Requirement: Dependency Sorting + +系统 SHALL 根据对象间依赖关系确定正确的执行顺序。 + +#### Scenario: Sort by dependency +- **WHEN** 对象之间存在依赖关系 +- **THEN** 被依赖的对象排在前面(如接口在类之前) + +#### Scenario: Same priority tie-breaker +- **WHEN** 多个对象无依赖关系 +- **THEN** 按照类型优先级排序(interface 最高,report 最低) + +#### Scenario: Detect cyclic dependency +- **WHEN** 对象间存在循环依赖 +- **THEN** 系统报告循环依赖错误并显示循环链 + +#### Scenario: Ignore external dependencies +- **WHEN** 对象依赖了不在当前项目中的外部对象 +- **THEN** 系统忽略该外部依赖,不报错 + +### Requirement: Batch Sync + +系统 SHALL 支持项目级批量同步操作。 + +#### Scenario: Sync all objects +- **WHEN** 用户执行 sync --all 命令 +- **THEN** 系统扫描项目、按依赖排序、逐个同步所有对象 + +#### Scenario: Dry run mode +- **WHEN** 用户执行 sync --all --dry-run +- **THEN** 系统仅显示将要同步的对象列表,不实际执行 + +#### Scenario: Fail fast mode +- **WHEN** 用户执行 sync --all --fail-fast +- **THEN** 任一对象同步失败时立即停止 + +#### Scenario: Sequential progress +- **WHEN** 批量同步进行中 +- **THEN** 系统逐一显示每个对象的同步进度和结果 + +### Requirement: Status Refresh + +系统 SHALL 刷新项目清单中所有对象在 SAP 系统中的状态。 + +#### Scenario: Refresh object status +- **WHEN** 用户执行 refresh 命令 +- **THEN** 系统查询每个对象在 SAP 中的最新状态并更新清单 diff --git a/openspec/changes/archive/2026-06-09-base-batch/tasks.md b/openspec/changes/archive/2026-06-09-base-batch/tasks.md new file mode 100644 index 0000000..02159a4 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-batch/tasks.md @@ -0,0 +1,47 @@ +## 1. Project Initialization + +- [x] 1.1 Create standard directory structure (reports/, classes/, interfaces/, functions/, domains/, dataelements/, tables/, structures/, tabletypes/) +- [x] 1.2 Generate empty manifest.json with version and timestamp +- [x] 1.3 Support --path for custom project directory + +## 2. Manifest Management + +- [x] 2.1 Define Manifest and ManifestEntry data classes +- [x] 2.2 Implement JSON serialization/deserialization (to_dict/from_dict) +- [x] 2.3 Implement load/save/upsert/remove/get operations +- [x] 2.4 Track system_status (active/inactive/not_exists) +- [x] 2.5 Track last_sync_result (success/failed/pending/skipped) +- [x] 2.6 Implement pending_objects() and active_up_to_date_objects() queries + +## 3. Directory Scanning + +- [x] 3.1 Map directory names to object types (DIRECTORY_TYPE_MAP) +- [x] 3.2 Parse .abap files into ScannedObject entries +- [x] 3.3 Handle functions/ subdirectory as group/module naming +- [x] 3.4 Skip hidden files (dot-prefixed) + +## 4. Dependency Sorting + +- [x] 4.1 Implement Kahn's algorithm for topological sort +- [x] 4.2 Add TYPE_PRIORITY tie-breaker (interface:50, class:60, function:70, report:80, others:90) +- [x] 4.3 Detect cyclic dependencies and raise CyclicDependencyError +- [x] 4.4 Ignore external dependencies not in current manifest + +## 5. Batch Sync + +- [x] 5.1 Implement sync --all mode with scan → sort → sequential sync pipeline +- [x] 5.2 Add --dry-run flag for preview-only mode +- [x] 5.3 Add --fail-fast flag for stop-on-first-error mode +- [x] 5.4 Update manifest after each sync attempt +- [x] 5.5 Validate --all mode excludes --name and --type + +## 6. Status Refresh + +- [x] 6.1 Query SAP system for each object's current status +- [x] 6.2 Update manifest with latest status information + +## 7. Transport Request (Basic) + +- [x] 7.1 Support --corr_nr in sync and create for transport binding +- [x] 7.2 Auto-detect object's bound transport request on lock +- [x] 7.3 Interactive transport request selection when needed diff --git a/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml b/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml new file mode 100644 index 0000000..f251ce7 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: base-core +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-base-core/proposal.md b/openspec/changes/archive/2026-06-09-base-core/proposal.md new file mode 100644 index 0000000..471e9f8 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/proposal.md @@ -0,0 +1,31 @@ +# Proposal: sap-cli 核心基础 — 连接与 CRUD + +## Why + +ABAP 开发者长期依赖 SAP GUI 进行日常开发,需要在 IDE 和 GUI 之间频繁切换。sap-cli 的目标是让开发者用自己喜欢的本地编辑器写 ABAP 代码,通过命令行完成与 SAP 的交互。首先需要建立可靠的基础:连接 SAP 系统、加载配置,并实现开发对象的基本 CRUD 操作。 + +## What Changes + +- 通过 SAP ADT REST API 建立连接与身份认证(HTTP Basic Auth + CSRF Token) +- 支持 config.ini 和环境变量两种配置方式 +- 实现 10 种 ABAP 开发对象类型的完整 CRUD: + - 源码对象:report、class、interface、function + - DDIC 对象:domain、dataelement、table、structure、tabletype + - 特殊对象:functiongroup(无源码) +- sync 命令实现锁定→写入→解锁→语法检查→激活的完整工作流 +- create 支持默认模板和自定义源码两种方式 +- DDIC 对象支持通过 JSON 定义文件创建 +- delete 操作需要用户交互确认 + +## Capabilities + +### New Capabilities +- `connection`: SAP ADT 连接建立、认证、配置加载 +- `object-lifecycle`: 10 种开发对象的创建、下载、同步、查询、删除 + +## Impact + +- 建立 sap-cli 的核心架构:main.py → ADTClient → SAP ADT REST API +- 定义 10 种对象类型的统一注册表(types.py) +- 建立统一异常体系(11 个错误类型) +- 为后续批量操作、搜索浏览等功能奠定基础 diff --git a/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md b/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md new file mode 100644 index 0000000..83c1b86 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/specs/connection/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: User Authentication + +系统 SHALL 通过 SAP ADT REST API 使用 HTTP Basic Auth 进行身份认证。 + +#### Scenario: Successful login +- **WHEN** 用户提供有效的 SAP 主机地址、客户端编号、用户名和密码 +- **THEN** 系统打印 "登录成功" 并建立可用会话 + +#### Scenario: Login failure +- **WHEN** 用户提供了无效的 SAP 凭据 +- **THEN** 系统打印错误信息并以非零退出码退出 + +#### Scenario: Config not found +- **WHEN** 用户未提供配置文件且未设置环境变量 +- **THEN** 系统打印配置缺失提示,列出需要的环境变量名,并以非零退出码退出 + +### Requirement: Configuration Loading + +系统 SHALL 支持 config.ini 文件和环境变量两种配置方式,环境变量优先。 + +#### Scenario: Load from config file +- **WHEN** 当前目录或指定路径存在有效的 config.ini +- **THEN** 系统从配置文件读取 SAP 连接参数 + +#### Scenario: Environment variable override +- **WHEN** 同时存在配置文件和环境变量 +- **THEN** 环境变量值覆盖配置文件中的对应项 + +#### Scenario: Client number default +- **WHEN** 配置中未指定 SAP 客户端编号 +- **THEN** 系统使用默认值 "100" + +### Requirement: Global Options + +系统 SHALL 支持全局命令行选项。 + +#### Scenario: Custom config path +- **WHEN** 用户通过 --config 指定配置文件路径 +- **THEN** 系统从指定路径加载配置 + +#### Scenario: No command specified +- **WHEN** 用户运行程序但未指定任何子命令 +- **THEN** 系统打印帮助信息并以退出码 1 退出 diff --git a/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..bf9659b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/specs/object-lifecycle/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 10 种 ABAP 对象类型。 + +#### Scenario: Create with default template +- **WHEN** 用户执行 create 命令,指定对象名称和类型,未提供自定义源码 +- **THEN** 系统使用内置模板生成源码并在 SAP 中创建对象 + +#### Scenario: Create with custom source file +- **WHEN** 用户通过 --source 参数指定本地 .abap 文件 +- **THEN** 系统读取文件内容作为源码并在 SAP 中创建对象 + +#### Scenario: Create DDIC object with definition +- **WHEN** 用户通过 --definition 参数指定 .json 文件创建 DDIC 对象 +- **THEN** 系统将 JSON 定义转换为 SAP 所需格式并创建对象 + +#### Scenario: Create already existing object +- **WHEN** SAP 系统中已存在同名同类型对象 +- **THEN** 系统报告对象已存在的错误 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码并保存到本地文件。 + +#### Scenario: Download existing object +- **WHEN** 用户执行 download 命令,SAP 系统存在该对象 +- **THEN** 系统下载源码,保存到指定目录,文件名为小写对象名+.abap + +#### Scenario: Download non-existent object +- **WHEN** 用户执行 download 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Download function type +- **WHEN** 用户下载 function 类型,名称格式为 "组名/模块名" +- **THEN** 系统正确解析组名和模块名,下载函数模块源码 + +#### Scenario: Download type without source +- **WHEN** 用户尝试下载不支持源码的类型(如 functiongroup) +- **THEN** 系统报告该类型不支持下载操作 + +### Requirement: Source Code Sync + +系统 SHALL 将本地源代码同步到 SAP 系统,执行锁定→写入→解锁→语法检查→激活流程。 + +#### Scenario: Full sync success +- **WHEN** 用户执行 sync 命令,本地文件和 SAP 对象均有效 +- **THEN** 系统依次执行各步骤,每步报告 ✓ 状态 + +#### Scenario: Auto-create on sync +- **WHEN** 用户同步一个 SAP 中不存在的对象 +- **THEN** 系统自动创建空对象后继续同步流程 + +#### Scenario: Syntax check failure +- **WHEN** 源码包含语法错误 +- **THEN** 系统报告语法错误详情(行号和描述),不继续激活 + +#### Scenario: Activation failure +- **WHEN** 语法检查通过但激活失败 +- **THEN** 系统报告激活错误详情 + +#### Scenario: Sync with specified transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统在锁定和写入时使用该传输请求 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中开发对象的元数据信息。 + +#### Scenario: Query existing object +- **WHEN** 用户执行 info 命令,SAP 系统存在该对象 +- **THEN** 系统显示名称、类型、描述、激活状态、负责人、修改时间等元数据 + +#### Scenario: Query non-existent object +- **WHEN** 用户执行 info 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除开发对象。 + +#### Scenario: Delete with confirmation +- **WHEN** 用户执行 delete 命令并确认删除 +- **THEN** 系统锁定、删除对象并报告成功 + +#### Scenario: Delete cancelled +- **WHEN** 用户执行 delete 命令但未确认 +- **THEN** 系统取消操作,不执行删除 + +#### Scenario: Delete non-existent object +- **WHEN** 用户尝试删除 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误 diff --git a/openspec/changes/archive/2026-06-09-base-core/tasks.md b/openspec/changes/archive/2026-06-09-base-core/tasks.md new file mode 100644 index 0000000..1765eed --- /dev/null +++ b/openspec/changes/archive/2026-06-09-base-core/tasks.md @@ -0,0 +1,69 @@ +## 1. Project Scaffolding & Entry Point + +- [x] 1.1 Create main.py entry point with argparse subcommand pattern +- [x] 1.2 Set up UTF-8 encoding for stdout/stderr +- [x] 1.3 Set up logging to log/adt_tools.log + +## 2. SAP ADT Connection + +- [x] 2.1 Implement ADTClient with HTTP Basic Auth + CSRF Token +- [x] 2.2 Support stateful/stateless session modes +- [x] 2.3 Implement context manager (`with ADTClient(...) as client:`) +- [x] 2.4 Disable SSL verification warnings + +## 3. Configuration Loading + +- [x] 3.1 Implement config.ini parsing with ConfigParser +- [x] 3.2 Support environment variable override (SAP_HOST, SAP_CLIENT, SAP_USER, SAP_PASSWORD) +- [x] 3.3 Support config file search order (--config → current dir → script dir) +- [x] 3.4 Default client to "100" when not specified + +## 4. Object Type Registry + +- [x] 4.1 Define ObjectTypeConfig with URI templates, content types, has_source flag +- [x] 4.2 Register 10 ABAP object types (report, class, interface, function, functiongroup, domain, dataelement, table, structure, tabletype) +- [x] 4.3 Implement parse_object_name for URI expansion +- [x] 4.4 Handle function type special naming (GROUP/MODULE format) +- [x] 4.5 Handle tabletype uppercase naming + +## 5. Download Command + +- [x] 5.1 Implement object existence check (HEAD/GET) +- [x] 5.2 Implement source code download with LF normalization +- [x] 5.3 Auto-create output directory +- [x] 5.4 Print source code preview (first 20 lines) + +## 6. Sync Command + +- [x] 6.1 Implement full pipeline: lock → write → unlock → syntax check → activate +- [x] 6.2 Auto-create object when not exists +- [x] 6.3 Support --corr_nr for transport request binding +- [x] 6.4 Handle lock failure with transport request selection +- [x] 6.5 Report progress per step with ✓/✗ indicators + +## 7. Create Command + +- [x] 7.1 Implement default template generation per type +- [x] 7.2 Support --source for custom source code +- [x] 7.3 Support --definition for DDIC JSON definitions +- [x] 7.4 Support --corr_nr for transport request in creation +- [x] 7.5 Support --path for project manifest update + +## 8. Info Command + +- [x] 8.1 Query object metadata via ADT GET +- [x] 8.2 Parse ADT core XML for name, type, description, status, author +- [x] 8.3 Validate response name matches request name +- [x] 8.4 Display status as ✓ 已激活 or ⚠️ 未激活 + +## 9. Delete Command + +- [x] 9.1 Check object existence before deletion +- [x] 9.2 Require user "yes" confirmation +- [x] 9.3 Execute lock → DELETE → unlock flow +- [x] 9.4 Support --path for manifest update + +## 10. Error Handling + +- [x] 10.1 Define unified exception hierarchy (SapCliError base + 11 subtypes) +- [x] 10.2 Exit with code 1 and user-friendly message on SapCliError diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml new file mode 100644 index 0000000..4feda86 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p0-include-list-profile-keyring +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md new file mode 100644 index 0000000..1eb195b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/proposal.md @@ -0,0 +1,29 @@ +# Proposal: P0 — Include 支持 + 对象列表 + 多系统配置 + 密钥安全 + +## Why + +Include 程序是 ABAP 大型报表拆分的常见手段,缺少支持会影响实际项目使用。对象列表浏览是开发者最基本的需求之一。多系统配置和安全密码存储是企业级使用的必要条件。 + +## What Changes + +- Include 程序作为 report 子类型支持(复用现有 report 逻辑) +- 对象列表浏览(按类型、前缀筛选) +- 多系统 profile 配置(config.ini 多 section) +- 密码通过系统 keyring 安全存储 + +## Capabilities + +### New Capabilities +(无新领域,均为已有领域的增强) + +### Modified Capabilities +- `connection`: 新增多 profile 切换能力 +- `search-browse`: 新增对象列表浏览能力 +- `config`: 新增配置显示、设置、密钥环存储能力 + +## Impact + +- types.py 新增 include 类型注册 +- config.py 增强 SapConfigManager 支持多 section +- 新增 auth.py 模块集成 keyring +- 新增 list 命令和对应 ADT 查询 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md new file mode 100644 index 0000000..25af4b7 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/config/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Configuration Display + +系统 SHALL 显示当前的 SAP 连接配置。 + +#### Scenario: Show current config +- **WHEN** 用户执行 config show 命令 +- **THEN** 系统显示当前生效的配置项(主机、客户端、用户名),密码以掩码显示 + +### Requirement: Configuration Setting + +系统 SHALL 支持通过命令行设置配置项。 + +#### Scenario: Set config value +- **WHEN** 用户执行 config set 命令并指定键和值 +- **THEN** 系统将配置值保存到配置文件中 + +### Requirement: Multi-Profile Management + +系统 SHALL 支持多 SAP 系统配置 profile。 + +#### Scenario: List profiles +- **WHEN** 用户执行 config list 命令 +- **THEN** 系统显示所有已配置的 profile 列表 + +### Requirement: Secure Password Storage + +系统 SHALL 支持通过系统密钥环安全存储 SAP 密码。 + +#### Scenario: Save password to keyring +- **WHEN** 用户配置密码存储选项 +- **THEN** 密码保存到操作系统密钥环而非明文配置文件 + +#### Scenario: Load password from keyring +- **WHEN** 配置文件中未指定密码但密钥环中存在 +- **THEN** 系统从密钥环读取密码 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md new file mode 100644 index 0000000..13e1857 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/connection/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Profile selection + +系统 SHALL 通过 --profile 参数支持选择 config.ini 中的不同 section 作为 SAP 连接配置。 + +#### Scenario: Use specific profile +- **WHEN** 用户通过 --profile DEV 指定配置 section +- **THEN** 系统从 config.ini 的 [DEV] section 读取连接参数 + +#### Scenario: Default profile +- **WHEN** 用户未指定 --profile +- **THEN** 系统使用默认的 [SAP] section + +#### Scenario: Profile not found +- **WHEN** 用户指定的 profile 在 config.ini 中不存在 +- **THEN** 系统报告配置 section 不存在的错误 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md new file mode 100644 index 0000000..f51631b --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/specs/search-browse/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Object Listing + +系统 SHALL 列出 SAP 系统中指定类型的开发对象。 + +#### Scenario: List by type with prefix +- **WHEN** 用户执行 list 命令并指定对象类型和前缀筛选 +- **THEN** 系统显示匹配的对象名称列表 + +#### Scenario: List without filter +- **WHEN** 用户执行 list 命令但未指定筛选条件 +- **THEN** 系统显示该类型的所有可访问对象 diff --git a/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md new file mode 100644 index 0000000..eb3e438 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p0-include-list-profile-keyring/tasks.md @@ -0,0 +1,24 @@ +## 1. Include Program Support + +- [x] 1.1 Register include as sub-type in types.py +- [x] 1.2 Reuse report ADT endpoints for include operations +- [x] 1.3 Handle include-specific template in create command + +## 2. Object Listing + +- [x] 2.1 Implement list command with type parameter +- [x] 2.2 Support prefix/wildcard filtering via ADT search API +- [x] 2.3 Display results in tabular format + +## 3. Multi-Profile Configuration + +- [x] 3.1 Enhance config.py to support multiple INI sections +- [x] 3.2 Add --profile/-p global option +- [x] 3.3 Default to [SAP] section when no profile specified + +## 4. Secure Password Storage + +- [x] 4.1 Create auth.py module with keyring integration +- [x] 4.2 Support saving password to system keyring +- [x] 4.3 Support loading password from keyring as fallback +- [x] 4.4 Implement config show/set/list subcommands diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml new file mode 100644 index 0000000..8a49582 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p1-cds-whereused-diff-transport-package +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md new file mode 100644 index 0000000..94d25cd --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/proposal.md @@ -0,0 +1,26 @@ +# Proposal: P1 — CDS View + Where-Used + Diff + 传输管理 + 包操作 + +## Why + +S/4HANA 现代化开发以 CDS View 为核心,支持 CDS 是战略必需。Where-Used 引用查询帮助开发者评估变更影响。代码差异对比防止误覆盖。传输请求和包管理是 SAP 运维的基础能力。 + +## What Changes + +- CDS View 的下载和创建 +- Where-Used 引用查询 +- 本地 vs SAP 代码差异对比 +- 传输请求高级管理(list/info/release/objects) +- ABAP 包操作(create/info) + +## Capabilities + +### Modified Capabilities +- `search-browse`: 新增 Where-Used 查询、代码差异对比、CDS View 操作、包操作 +- `transport`: 新增传输请求列表、详情、释放、对象查看 + +## Impact + +- client.py 新增 CDS/Where-Used/Transport/Package 相关 ADT API 方法 +- 新增 search.py、diff_cmd.py、transport.py、package_cmd.py、cds.py 命令模块 +- 新增 5 个子命令:whereused、search、diff、cds、package +- transport 命令新增 4 个子操作 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md new file mode 100644 index 0000000..af24c85 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/search-browse/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Where-Used Query + +系统 SHALL 查询指定对象在其他开发对象中的引用关系。 + +#### Scenario: Find references +- **WHEN** 用户执行 whereused 命令并指定对象名称和类型 +- **THEN** 系统显示所有引用该对象的开发对象列表 + +#### Scenario: No references found +- **WHEN** 指定对象没有被任何其他对象引用 +- **THEN** 系统显示无引用结果的信息 + +### Requirement: Source Code Search + +系统 SHALL 在 SAP 系统中搜索源代码内容。 + +#### Scenario: Search by keyword +- **WHEN** 用户执行 search 命令并提供搜索关键词 +- **THEN** 系统显示包含该关键词的对象名称、类型和匹配行 + +#### Scenario: Search no results +- **WHEN** 搜索关键词没有匹配结果 +- **THEN** 系统显示无搜索结果的信息 + +### Requirement: Code Diff + +系统 SHALL 对比本地源码与 SAP 系统中当前版本的差异。 + +#### Scenario: Diff with differences +- **WHEN** 用户执行 diff 命令,本地文件与 SAP 版本存在差异 +- **THEN** 系统显示差异内容(新增、删除、修改的行) + +#### Scenario: Diff identical +- **WHEN** 本地文件与 SAP 版本完全一致 +- **THEN** 系统显示无差异的信息 + +### Requirement: CDS View Operations + +系统 SHALL 支持 CDS View 的下载和创建操作。 + +#### Scenario: Download CDS view +- **WHEN** 用户执行 cds download 命令并指定 CDS View 名称 +- **THEN** 系统下载 CDS View 定义并保存到本地 + +#### Scenario: Create CDS view +- **WHEN** 用户执行 cds create 命令并指定名称和描述 +- **THEN** 系统在 SAP 中创建新的 CDS View + +### Requirement: Package Operations + +系统 SHALL 支持 ABAP 包的查询和创建操作。 + +#### Scenario: Query package info +- **WHEN** 用户执行 package info 命令并指定包名 +- **THEN** 系统显示包的描述、包含的对象数量等信息 + +#### Scenario: Create package +- **WHEN** 用户执行 package create 命令并指定包名和描述 +- **THEN** 系统在 SAP 中创建新的 ABAP 包 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md new file mode 100644 index 0000000..38c20bd --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/specs/transport/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Transport Request Listing + +系统 SHALL 列出 SAP 系统中的传输请求。 + +#### Scenario: List transport requests +- **WHEN** 用户执行 transport list 命令 +- **THEN** 系统显示当前用户的传输请求列表(请求号、描述、状态) + +### Requirement: Transport Request Info + +系统 SHALL 查询指定传输请求的详细信息。 + +#### Scenario: Query transport info +- **WHEN** 用户执行 transport info 命令并指定传输请求号 +- **THEN** 系统显示传输请求的所有者、状态、包含的对象数量等信息 + +### Requirement: Transport Request Release + +系统 SHALL 释放传输请求。 + +#### Scenario: Release transport request +- **WHEN** 用户执行 transport release 命令并指定传输请求号 +- **THEN** 系统释放该传输请求 + +### Requirement: Transport Objects Listing + +系统 SHALL 列出传输请求中包含的对象。 + +#### Scenario: List objects in transport +- **WHEN** 用户执行 transport objects 命令并指定传输请求号 +- **THEN** 系统显示该传输请求中包含的所有开发对象列表 diff --git a/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md new file mode 100644 index 0000000..bd4afc2 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p1-cds-whereused-diff-transport-package/tasks.md @@ -0,0 +1,36 @@ +## 1. CDS View Support + +- [x] 1.1 Register CDS View type and ADT endpoints +- [x] 1.2 Implement cds download subcommand +- [x] 1.3 Implement cds create subcommand +- [x] 1.4 Handle DDL source format (non-ABAP) + +## 2. Where-Used Query + +- [x] 2.1 Implement whereused command with name and type parameters +- [x] 2.2 Parse ADT where-used XML response +- [x] 2.3 Display referencing objects with type and name + +## 3. Source Code Search + +- [x] 3.1 Implement search command with keyword query +- [x] 3.2 Parse ADT code search response +- [x] 3.3 Display matching objects with context lines + +## 4. Code Diff + +- [x] 4.1 Implement diff command comparing local file vs SAP version +- [x] 4.2 Download SAP version for comparison +- [x] 4.3 Compute and display unified diff output + +## 5. Transport Management + +- [x] 5.1 Implement transport list subcommand +- [x] 5.2 Implement transport info subcommand with --corr_nr +- [x] 5.3 Implement transport release subcommand with --corr_nr +- [x] 5.4 Implement transport objects subcommand with --corr_nr + +## 6. Package Operations + +- [x] 6.1 Implement package info subcommand +- [x] 6.2 Implement package create subcommand with --description diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml new file mode 100644 index 0000000..552bf67 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: p2-atc-format-analyze-scaffold +status: archived +archived_at: "2026-06-09" diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md new file mode 100644 index 0000000..3ee9284 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/proposal.md @@ -0,0 +1,24 @@ +# Proposal: P2 — ATC 检查 + 代码格式化 + 依赖分析 + 项目脚手架 + +## Why + +代码质量保障和开发者效率是工具成熟的标志。ATC 检查提供专业级代码审查,Pretty Printer 统一代码风格,依赖分析自动化项目管理,脚手架加速项目初始化。 + +## What Changes + +- ATC (ABAP Test Cockpit) 代码检查集成 +- ABAP Pretty Printer 代码格式化 +- 项目级依赖自动分析 +- 预定义模板项目脚手架 + +## Capabilities + +### Modified Capabilities +- `quality`: 新增 ATC 检查、代码格式化、依赖分析、项目脚手架 + +## Impact + +- 新增 check、format、analyze、scaffold 四个命令 +- client.py 新增 ATC check、Pretty Printer ADT API 方法 +- 新增 quality.py、analyze.py、scaffold.py 命令模块 +- analyze 和 scaffold 不需要 SAP 连接(本地操作) diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md new file mode 100644 index 0000000..1072bce --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/specs/quality/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: ATC Code Check + +系统 SHALL 对 SAP 开发对象执行 ABAP Test Cockpit (ATC) 代码检查。 + +#### Scenario: Check with findings +- **WHEN** 用户执行 check 命令并指定对象名称和类型 +- **THEN** 系统显示 ATC 检查发现的问题列表(优先级、位置、描述) + +#### Scenario: Check clean +- **WHEN** ATC 检查未发现任何问题 +- **THEN** 系统显示检查通过的信息 + +### Requirement: Code Formatting + +系统 SHALL 对 SAP 开发对象执行 ABAP Pretty Printer 格式化。 + +#### Scenario: Format source code +- **WHEN** 用户执行 format 命令并指定对象名称和类型 +- **THEN** 系统对 SAP 中的源码执行格式化并报告结果 + +### Requirement: Dependency Analysis + +系统 SHALL 自动分析项目中对象的依赖关系。 + +#### Scenario: Analyze project dependencies +- **WHEN** 用户执行 analyze 命令并指定项目路径 +- **THEN** 系统扫描源码中的依赖引用并生成依赖报告 + +### Requirement: Project Scaffolding + +系统 SHALL 根据预定义模板创建 ABAP 项目结构。 + +#### Scenario: Scaffold from template +- **WHEN** 用户执行 scaffold 命令并指定名称和模板类型 +- **THEN** 系统基于模板生成包含源码文件的项目结构 + +#### Scenario: List available templates +- **WHEN** 用户执行 scaffold 命令但未指定模板 +- **THEN** 系统显示可用的模板列表 diff --git a/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md new file mode 100644 index 0000000..77b6aee --- /dev/null +++ b/openspec/changes/archive/2026-06-09-p2-atc-format-analyze-scaffold/tasks.md @@ -0,0 +1,26 @@ +## 1. ATC Code Check + +- [x] 1.1 Implement check command with name and type parameters +- [x] 1.2 Integrate ADT ATC checkruns API +- [x] 1.3 Parse and display findings with priority, location, description +- [x] 1.4 Handle clean check result + +## 2. Code Formatting + +- [x] 2.1 Implement format command with ABAP Pretty Printer integration +- [x] 2.2 Integrate ADT Pretty Printer API +- [x] 2.3 Report formatting result + +## 3. Dependency Analysis + +- [x] 3.1 Implement analyze command for project-level scanning +- [x] 3.2 Parse TYPE REF TO, CALL METHOD, PERFORM statements +- [x] 3.3 Generate dependency report +- [x] 3.4 Work without SAP connection (local-only) + +## 4. Project Scaffolding + +- [x] 4.1 Implement scaffold command with template parameter +- [x] 4.2 Create predefined templates (alv-report, bapi-wrapper, interface-class, data-model) +- [x] 4.3 Display available templates when none specified +- [x] 4.4 Work without SAP connection (local-only) diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml new file mode 100644 index 0000000..c40440c --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +change: add-upload-syntax-check +status: archived +archived_at: "2026-06-13" diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md new file mode 100644 index 0000000..6ce0eef --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/proposal.md @@ -0,0 +1,19 @@ +# Proposal: 新增 upload 和 syntax-check 独立命令 + +## Why + +sync 命令耦合了五步操作,无法满足只上传不激活、只检查语法的场景。拆分为原子命令后,用户可按需组合操作。 + +## What Changes + +- 新增 upload 命令(锁定→写入→解锁,不检查不激活) +- 新增 syntax-check 命令(检查远程对象语法,不上传不激活) + +## Impact + +- 新增 sapcli/commands/upload.py +- 新增 sapcli/commands/syntax_check.py +- parser.py 新增两个命令注册 +- app.py 新增命令路由 +- object-lifecycle spec 新增两个 Requirement +- sync 命令完全不变 diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..0533f73 --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/specs/object-lifecycle/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Source Code Upload + +系统 SHALL 上传本地源代码到 SAP 系统,执行锁定→写入→解锁流程,不执行语法检查和激活。 +upload 不会自动创建对象——上传前对象必须已存在于 SAP 系统中。 + +#### Scenario: Upload success +- **WHEN** 用户执行 upload 命令,对象已存在于 SAP,本地文件有效 +- **THEN** 系统执行锁定、写入源码、解锁,报告写入的字符数和行数,不执行语法检查和激活 + +#### Scenario: Upload non-existent object +- **WHEN** 用户上传一个 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误(不自动创建,区别于 sync) + +#### Scenario: Upload with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统使用该传输请求号进行锁定和写入 + +#### Scenario: Upload type without source +- **WHEN** 用户尝试上传不支持源码的类型(如 functiongroup、tabletype、messageclass、searchhelp、lockobject) +- **THEN** 系统报告该类型不支持上传操作 + +#### Scenario: Upload local file not found +- **WHEN** 用户指定的本地源码文件路径不存在 +- **THEN** 系统报告文件不存在的错误 + +### Requirement: Syntax Check + +系统 SHALL 对 SAP 远程对象的源代码执行语法检查,不上传代码、不激活。 +syntax-check 检查 SAP 上现有对象的源码(通常是 active 状态),用于验证结构变更后的依赖影响。 + +#### Scenario: Syntax check passed +- **WHEN** 用户执行 syntax-check 命令,对象的源码无语法错误 +- **THEN** 系统报告语法检查通过 + +#### Scenario: Syntax check failed +- **WHEN** 对象源码包含语法错误 +- **THEN** 系统报告语法检查未通过,显示每个错误的行号和描述 + +#### Scenario: Syntax check non-existent object +- **WHEN** 用户对 SAP 中不存在的对象执行语法检查 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Syntax check type without source +- **WHEN** 用户对不支持源码的类型(如 functiongroup、tabletype)执行语法检查 +- **THEN** 系统报告该类型不支持语法检查操作 diff --git a/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md new file mode 100644 index 0000000..c9098ac --- /dev/null +++ b/openspec/changes/archive/2026-06-13-add-upload-syntax-check/tasks.md @@ -0,0 +1,44 @@ +## 1. OpenSpec 规范 + +- [x] 1.1 创建 openspec/changes/add-upload-syntax-check/ 变更目录 +- [x] 1.2 编写 proposal.md(Why / What Changes / Impact) +- [x] 1.3 编写 specs/object-lifecycle/spec.md(ADDED: Source Code Upload、Syntax Check) +- [x] 1.4 编写 tasks.md 任务清单 + +## 2. TDD 测试(先写测试,必须 RED) + +- [x] 2.1 TestUploadCommand:test_upload_success(断言 syntax_check/activate 未被调用) +- [x] 2.2 TestUploadCommand:test_upload_nonexistent_object(ObjectNotFoundError) +- [x] 2.3 TestUploadCommand:test_upload_type_without_source(InvalidNameError) +- [x] 2.4 TestUploadCommand:test_upload_with_corr_nr(通过 --corr_nr 指定) +- [x] 2.5 TestUploadCommand:test_upload_file_not_found(ConfigError) +- [x] 2.6 TestSyntaxCheckCommand:test_syntax_check_passed(输出含"通过") +- [x] 2.7 TestSyntaxCheckCommand:test_syntax_check_failed(输出含错误行号) +- [x] 2.8 TestSyntaxCheckCommand:test_syntax_check_nonexistent(ObjectNotFoundError) +- [x] 2.9 TestSyntaxCheckCommand:test_syntax_check_type_without_source(InvalidNameError) +- [x] 2.10 运行测试确认全部 RED(9 个测试 ModuleNotFoundError 失败) + +## 3. 实现命令 + +- [x] 3.1 实现 sapcli/commands/upload.py(cmd_upload,参照 activate.py 结构) +- [x] 3.2 实现 sapcli/commands/syntax_check.py(cmd_syntax_check) + +## 4. Parser 注册 + App 路由 + +- [x] 4.1 parser.py 注册 upload_parser(--name/--type/--path/--corr_nr) +- [x] 4.2 parser.py 注册 syntax-check 的 sc_parser(--name/--type) +- [x] 4.3 commands/__init__.py 导出 cmd_upload / cmd_syntax_check +- [x] 4.4 app.py 导入并路由 upload / syntax-check + +## 5. 验证(必须 GREEN) + +- [x] 5.1 运行 pytest tests/unit/ 全部通过(363 passed) +- [x] 5.2 python main.py upload --help / syntax-check --help 正常 +- [x] 5.3 python main.py --help 出现两个新命令 +- [x] 5.4 python main.py sync --help 参数未变(回归验证通过) +- [x] 5.5 openspec validate add-upload-syntax-check → valid + +## 6. 收尾 + +- [x] 6.1 tasks.md 所有 checkbox 标记为 [x] +- [x] 6.2 .openspec.yaml status 改为 completed diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..b4a3c1a --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,25 @@ +schema: spec-driven + +context: | + Tech stack: Python 3.11+, requests, SAP ADT REST API + CLI framework: argparse (subcommand pattern) + Architecture: CLI → ADT REST API → SAP System + Testing: unittest + Code style: PEP 8, type hints, f-strings + SAP object types: report, class, interface, function, functiongroup, domain, dataelement, table, structure, tabletype + +rules: + proposal: + - Focus on user-visible behavior changes, not implementation + - Identify affected capabilities by checking existing specs + specs: + - Use WHEN/THEN format for scenarios (not Gherkin code blocks) + - Describe observable behavior only, no ADT endpoints or class names + - Every requirement MUST have at least one scenario + - Use RFC 2119 keywords: SHALL/MUST for mandatory, SHOULD for recommended + design: + - Include ADT endpoint details and technical rationale + - Consider SAP transport management implications + tasks: + - Each task independently verifiable + - "Use checkbox format: - [ ] X.Y Description" diff --git a/openspec/spec-coverage-report.md b/openspec/spec-coverage-report.md new file mode 100644 index 0000000..801f98d --- /dev/null +++ b/openspec/spec-coverage-report.md @@ -0,0 +1,234 @@ +# Spec-to-Code 需求覆盖验证报告 + +> 生成时间: 2026-06-09 +> 对照 `openspec/specs/` 下 7 个 spec 文件 vs `sapcli/` 源码 + +--- + +## 1. Connection (connection/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 1.1 | Successful login | ✅已实现 | `client.py:login()` 使用 HTTP Basic Auth,`app.py:101-102` 打印"登录成功" | +| 1.2 | Login failure | ✅已实现 | `client.py:62` raise `LoginError`,`app.py:144-146` 捕获 `SapCliError` 以非零退出码退出 | +| 1.3 | Config not found | ✅已实现 | `config.py:25-29` `SAPConfig.__post_init__` 检查缺失字段抛 `ConfigError`,`app.py:91-95` 打印缺失提示和环境变量名称 | +| 1.4 | Load from config file | ✅已实现 | `config.py:32-49` 搜索并读取 `config.ini` | +| 1.5 | Environment variable override | ✅已实现 | `config.py:53-57` `os.environ.get("SAP_XXX", sap.get(...))` 环境变量优先 | +| 1.6 | Profile selection (--profile) | ❌未实现 | `parser.py:69` 定义了 `--profile` 参数,但 `config.py:51` 硬编码 `cfg["SAP"]`,未使用 `args.profile` | +| 1.7 | Client number default "100" | ✅已实现 | `config.py:55` `sap.get("client", "100")` | +| 1.8 | Custom config path (--config) | ✅已实现 | `parser.py:67` + `app.py:88` 传入 `args.config` | +| 1.9 | No command specified | ✅已实现 | `app.py:77-79` 检测 `not args.command` 打印帮助并以 exit(1) 退出 | + +**小计: 8/9 ✅, 1 ❌** + +--- + +## 2. Object Lifecycle (object-lifecycle/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 2.1 | Create with default template | ⚠️部分实现 | `crud.py:31-58` 仅有 report/class/function/interface 四种模板,其他 12 种类型无默认模板 | +| 2.2 | Create with custom source file | ✅已实现 | `crud.py:690-695` `--source` 参数读取文件 | +| 2.3 | Create DDIC with --definition | ✅已实现 | `crud.py:771-876` domain/dataelement/table/structure/tabletype 有 JSON→XML 转换 | +| 2.4 | Create message class | ❌未实现 | `types.py` 未注册 `messageclass` 对象类型,无法 create/download/info/delete | +| 2.5 | Create database view | ❌未实现 | `types.py` 未注册 `view` 对象类型 | +| 2.6 | Create search help | ❌未实现 | `types.py` 未注册 `searchhelp` 对象类型 | +| 2.7 | Create lock object | ❌未实现 | `types.py` 未注册 `lockobject` 对象类型 | +| 2.8 | Create already existing object | ✅已实现 | `crud.py:642-644` 检测 `object_exists` 后抛 `ObjectAlreadyExistsError` | +| 2.9 | Create with --corr_nr | ✅已实现 | `parser.py:110` + `crud.py:652-655` 传入传输请求号 | +| 2.10 | Download existing object | ✅已实现 | `crud.py:73-122` 完整实现,文件名小写+.abap | +| 2.11 | Download non-existent object | ✅已实现 | `crud.py:95-98` 检测不存在抛 `ObjectNotFoundError` | +| 2.12 | Download function type (group/module) | ✅已实现 | `types.py:152-167` 解析 `组名/模块名` 格式 | +| 2.13 | Download type without source | ✅已实现 | `crud.py:82-85` 检测 `src_uri is None` 报错 | +| 2.14 | Download view DDL source | ❌未实现 | `view` 类型未在 `types.py` 注册 | +| 2.15 | Full sync success (lock→write→unlock→check→activate) | ✅已实现 | `crud.py:226-317` 完整五步流程,每步报告进度 | +| 2.16 | Auto-create on sync | ✅已实现 | `crud.py:213-224` 检测不存在则自动创建空对象 | +| 2.17 | Syntax check failure | ✅已实现 | `crud.py:277-287` 报告行号和错误描述 | +| 2.18 | Activation failure | ✅已实现 | `crud.py:308-317` 报告激活错误详情 | +| 2.19 | Sync with --corr_nr | ✅已实现 | `crud.py:230-231` 贯穿整个流程 | +| 2.20 | Query existing object info | ✅已实现 | `crud.py:371-449` 显示名称/类型/描述/状态/负责人等元数据 | +| 2.21 | Query non-existent object | ✅已实现 | `crud.py:406-408` 404 检测 | +| 2.22 | Query message class info | ❌未实现 | `messageclass` 未注册 | +| 2.23 | Query view info | ❌未实现 | `view` 未注册 | +| 2.24 | Delete with confirmation | ✅已实现 | `crud.py:483-486` input("yes") 确认 | +| 2.25 | Delete cancelled | ✅已实现 | `crud.py:484-486` 非确认则取消 | +| 2.26 | Delete non-existent object | ✅已实现 | `crud.py:474-477` 检测不存在抛 `ObjectNotFoundError` | +| 2.27 | Delete message class | ❌未实现 | `messageclass` 未注册 | +| 2.28 | Delete lock object | ❌未实现 | `lockobject` 未注册 | + +**小计: 16/28 ✅, 7 ❌, 1 ⚠️** + +### 缺失对象类型汇总 + +以下 5 种对象类型在 spec 中列出但未在 `types.py` 中注册: +- **messageclass** — 无 ObjectTypeConfig、无 URI 模板、无 create/download/info/delete +- **view** — 无 ObjectTypeConfig、无 URI 模板 +- **searchhelp** — 无 ObjectTypeConfig、无 URI 模板 +- **lockobject** — 无 ObjectTypeConfig、无 URI 模板 +- **include** — 无 ObjectTypeConfig(仅在 scanner.py 的 DIRECTORY_TYPE_MAP 中映射,但 parse_object_name 会报错) +- **cdsview** — 无 ObjectTypeConfig(CDS 通过独立命令 `cds` 子系统处理,不走通用 CRUD 流程) + +--- + +## 3. Batch Operations (batch-ops/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 3.1 | Init new project | ✅已实现 | `batch.py:19-96` 扫描→查询→生成 manifest.json | +| 3.2 | Manifest tracks objects | ✅已实现 | `manifest.py` 完整 CRUD + to_dict/from_dict 持久化 | +| 3.3 | Query pending objects | ✅已实现 | `manifest.py:139-146` `pending_objects()` 方法 | +| 3.4 | Scan standard directories | ✅已实现 | `scanner.py:13-29` 14 种目录映射 + `_scan_flat_directory` | +| 3.5 | Function directory structure | ✅已实现 | `scanner.py:91-121` 子目录=组名,文件名=模块名 | +| 3.6 | Skip hidden files | ✅已实现 | `scanner.py:77` `filename.startswith(".")` 跳过 | +| 3.7 | Sort by dependency | ✅已实现 | `sorter.py:40-109` Kahn 算法拓扑排序 + TYPE_PRIORITY | +| 3.8 | Detect cyclic dependency | ✅已实现 | `sorter.py:100-105` 抛 `CyclicDependencyError` 并显示循环链 | +| 3.9 | Ignore external dependencies | ✅已实现 | `sorter.py:63-65` `dep not in names` 时忽略 | +| 3.10 | Sync all objects (--all) | ✅已实现 | `batch.py:164-328` 完整批量同步 | +| 3.11 | Dry run mode (--dry-run) | ✅已实现 | `batch.py:172,224-241` 仅显示计划不执行 | +| 3.12 | Fail fast mode (--fail-fast) | ✅已实现 | `batch.py:173,287-289` 失败立即 break | +| 3.13 | Refresh object status | ✅已实现 | `batch.py:99-161` 逐个查询 SAP 状态更新清单 | + +**小计: 13/13 ✅** + +--- + +## 4. Search and Browse (search-browse/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 4.1 | List by type with prefix | ⚠️部分实现 | `search.py:31-71` 命令层已实现,但调用 `client.list_objects()` — **client.py 中缺少该方法定义** | +| 4.2 | List without filter | ⚠️部分实现 | 同上,命令层有,client 层缺失 | +| 4.3 | Find references (whereused) | ⚠️部分实现 | `search.py:74-109` 命令层已实现,但调用 `client.where_used()` — **client.py 中缺少该方法定义** | +| 4.4 | No references found | ⚠️部分实现 | `search.py:97-98` 显示无引用信息,但依赖缺失的 client 方法 | +| 4.5 | Search by keyword | ⚠️部分实现 | `search.py:112-147` 命令层已实现,但调用 `client.search_code()` — **client.py 中缺少该方法定义** | +| 4.6 | Search no results | ⚠️部分实现 | `search.py:136-137` 显示无结果信息,但依赖缺失的 client 方法 | +| 4.7 | Diff with differences | ⚠️部分实现 | `diff_cmd.py:16-90` 命令层完整,但调用 `client.read_source_for_diff()` — **client.py 中缺少该方法定义** | +| 4.8 | Diff identical | ⚠️部分实现 | `diff_cmd.py:70-72` 检测无差异,但依赖缺失的 client 方法 | +| 4.9 | Download CDS view | ⚠️部分实现 | `cds.py:31-61` 命令层完整,但调用 `client.get_cds_source()` — **client.py 中缺少该方法定义** | +| 4.10 | Create CDS view | ⚠️部分实现 | `cds.py:130-171` 命令层完整,但调用 `client.create_cds()` — **client.py 中缺少该方法定义** | +| 4.11 | Query package info | ⚠️部分实现 | `package_cmd.py:56-77` 命令层完整,但调用 `client.get_package_info()` — **client.py 中缺少该方法定义** | +| 4.12 | Create package | ⚠️部分实现 | `package_cmd.py:29-53` 命令层完整,但调用 `client.create_package()` — **client.py 中缺少该方法定义** | + +**小计: 0/12 ✅, 12 ⚠️ (命令层均已完成,但 ADTClient 缺少对应的 API 方法)** + +### client.py 缺失的方法清单 + +以下方法被 commands/ 调用但未在 `client.py` 中定义: +1. `list_objects(obj_type, package, prefix)` — 被 search.py, package_cmd.py 调用 +2. `where_used(name, obj_uri, adt_type)` — 被 search.py 调用 +3. `search_code(query, obj_type)` — 被 search.py 调用 +4. `read_source_for_diff(name, obj_type)` — 被 diff_cmd.py 调用 +5. `get_cds_source(name)` — 被 cds.py 调用 +6. `create_cds(name, description, ddl_source)` — 被 cds.py 调用 +7. `create_package(name, description, superpackage)` — 被 package_cmd.py 调用 +8. `get_package_info(name)` — 被 package_cmd.py 调用 + +--- + +## 5. Transport Management (transport/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 5.1 | List transport requests | ✅已实现 | `transport.py:32-56` + `client.py:249-280` `list_transport_requests()` | +| 5.2 | Query transport info | ⚠️部分实现 | `transport.py:59-79` 命令层已实现,但调用 `client.transport_info()` — **client.py 中缺少该方法定义** | +| 5.3 | Release transport request | ⚠️部分实现 | `transport.py:82-107` 命令层已实现,但调用 `client.transport_release()` — **client.py 中缺少该方法定义** | +| 5.4 | List objects in transport | ⚠️部分实现 | `transport.py:110-136` 命令层已实现,但调用 `client.transport_objects()` — **client.py 中缺少该方法定义** | + +**小计: 1/4 ✅, 3 ⚠️** + +### client.py 缺失的方法(Transport) +1. `transport_info(corr_nr)` — 传输请求详情查询 +2. `transport_release(corr_nr)` — 释放传输请求 +3. `transport_objects(corr_nr)` — 传输请求中的对象列表 + +--- + +## 6. Code Quality (quality/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 6.1 | Check with findings | ⚠️部分实现 | `quality.py:14-61` 命令层完整,但调用 `client.atc_check()` — **client.py 中缺少该方法定义** | +| 6.2 | Check clean | ⚠️部分实现 | `quality.py:38-39` 显示通过信息,但依赖缺失的 client 方法 | +| 6.3 | Format source code | ⚠️部分实现 | `quality.py:64-132` 命令层完整,但调用 `client.pretty_print()` — **client.py 中缺少该方法定义** | +| 6.4 | Analyze project dependencies | ✅已实现 | `analyze.py:39-126` 本地源码解析,正则匹配 TYPE REF TO / CALL FUNCTION 等模式 | +| 6.5 | Scaffold from template | ✅已实现 | `scaffold.py:198-239` 4 种模板(alv-report/bapi-wrapper/interface-class/data-model) | +| 6.6 | List available templates | ❌未实现 | spec 要求不指定模板时列出可用模板,但 `parser.py:207` `--template` 为 `required=True`,无法不指定 | + +**小计: 2/6 ✅, 3 ⚠️, 1 ❌** + +### client.py 缺失的方法(Quality) +1. `atc_check(name, obj_uri, variant)` — ATC 代码检查 +2. `pretty_print(source)` — ABAP Pretty Printer + +--- + +## 7. Configuration Management (config/spec.md) + +| # | Scenario | 状态 | 说明 | +|---|----------|------|------| +| 7.1 | Show current config | ✅已实现 | `config_cmd.py:20-40` 显示主机/客户端/用户名,密码掩码显示 | +| 7.2 | Set config value | ❌未实现 | `parser.py:123-126` 未定义 `config set` 子命令,config_cmd.py 无 set 逻辑 | +| 7.3 | List profiles | ✅已实现 | `config_cmd.py:43-66` `list-profiles` 子命令 | +| 7.4 | Use specific profile | ❌未实现 | `--profile` 参数已定义但 `config.py` 硬编码使用 `[SAP]` section | +| 7.5 | Save password to keyring | ⚠️部分实现 | `auth.py` 有 `set_password()` 和 `cmd_auth_login()`,但 **auth 模块未集成到 app.py 命令路由**,parser.py 也未注册 `auth` 子命令 | +| 7.6 | Load password from keyring | ⚠️部分实现 | `auth.py:resolve_password()` 存在,但 **未被 config.py 或 app.py 调用** | + +**小计: 2/6 ✅, 2 ❌, 2 ⚠️** + +--- + +## 汇总统计 + +| Spec 领域 | 总需求 | ✅已实现 | ❌未实现 | ⚠️部分实现 | 覆盖率 | +|-----------|--------|---------|---------|-----------|--------| +| Connection | 9 | 8 | 1 | 0 | 88.9% | +| Object Lifecycle | 28 | 16 | 7 | 1 | 57.1% | +| Batch Operations | 13 | 13 | 0 | 0 | 100% | +| Search & Browse | 12 | 0 | 0 | 12 | 0% (命令层100%,API层0%) | +| Transport | 4 | 1 | 0 | 3 | 25% | +| Code Quality | 6 | 2 | 1 | 3 | 33.3% | +| Config Management | 6 | 2 | 2 | 2 | 33.3% | +| **总计** | **78** | **42** | **11** | **21** | **53.8%** | + +--- + +## 关键缺失点总结 + +### A. client.py 缺失的 13 个 ADT API 方法 + +这些方法被 commands/ 层调用但未实现,是最关键的缺口: + +| 方法 | 调用者 | 涉及 Spec | +|------|--------|-----------| +| `list_objects(obj_type, package, prefix)` | search.py, package_cmd.py | search-browse | +| `where_used(name, obj_uri, adt_type)` | search.py | search-browse | +| `search_code(query, obj_type)` | search.py | search-browse | +| `read_source_for_diff(name, obj_type)` | diff_cmd.py | search-browse | +| `get_cds_source(name)` | cds.py | search-browse | +| `create_cds(name, description, ddl_source)` | cds.py | search-browse | +| `create_package(name, description, superpackage)` | package_cmd.py | search-browse | +| `get_package_info(name)` | package_cmd.py | search-browse | +| `transport_info(corr_nr)` | transport.py | transport | +| `transport_release(corr_nr)` | transport.py | transport | +| `transport_objects(corr_nr)` | transport.py | transport | +| `atc_check(name, obj_uri, variant)` | quality.py | quality | +| `pretty_print(source)` | quality.py | quality | + +### B. types.py 缺失的 5 个对象类型注册 + +| 类型 | Spec 要求 | 当前状态 | +|------|----------|---------| +| messageclass | create/download/info/delete | ❌ 未注册,无 URI | +| view | create/download/info | ❌ 未注册,无 URI | +| searchhelp | create | ❌ 未注册,无 URI | +| lockobject | create/delete | ❌ 未注册,无 URI | +| include | (扫描可用) | ❌ scanner.py 映射了目录但 types.py 未注册 | + +### C. 配置/认证缺口 + +| 缺失项 | 详情 | +|--------|------| +| `--profile` 不可用 | parser.py 定义了参数但 config.py 忽略,始终读 [SAP] section | +| `config set` 子命令 | spec 要求通过命令行设置配置项,parser.py 未注册,无实现 | +| keyring 集成 | auth.py 模块完整但未被 app.py/config.py 引入调用链 | +| scaffold 无模板列表 | `--template` 为 required,无法不指定来列出模板 | diff --git a/openspec/specs/batch-ops/spec.md b/openspec/specs/batch-ops/spec.md new file mode 100644 index 0000000..6679956 --- /dev/null +++ b/openspec/specs/batch-ops/spec.md @@ -0,0 +1,83 @@ +# Batch Operations Specification + +## Purpose + +定义 sap-cli 的项目级批量操作行为,包括项目初始化、清单管理、目录扫描、依赖排序和批量同步。 + +## Requirements + +### Requirement: Project Initialization + +系统 SHALL 初始化一个 ABAP 项目目录结构。 + +#### Scenario: Init new project +- **WHEN** 用户执行 init 命令并指定项目路径 +- **THEN** 系统创建标准目录结构(reports/、classes/ 等约定目录)和空的 manifest.json + +### Requirement: Manifest Management + +系统 SHALL 管理项目清单文件,记录所有开发对象的状态。 + +#### Scenario: Manifest tracks objects +- **WHEN** 对象被创建、同步或删除后 +- **THEN** 清单文件自动更新对象的状态、最后同步时间和同步结果 + +#### Scenario: Query pending objects +- **WHEN** 清单中存在未成功同步的对象 +- **THEN** 系统能够识别并列出这些待处理对象 + +### Requirement: Directory Scanning + +系统 SHALL 扫描本地项目目录,自动识别 ABAP 开发对象文件。 + +#### Scenario: Scan standard directories +- **WHEN** 项目目录包含约定命名的子目录(reports/、classes/、functions/ 等)及其中的 .abap 文件 +- **THEN** 系统识别每个文件对应的对象名称、类型和路径 + +#### Scenario: Function directory structure +- **WHEN** 扫描 functions/ 目录 +- **THEN** 子目录名解析为函数组名,文件名解析为函数模块名 + +#### Scenario: Skip hidden files +- **WHEN** 目录中存在以 . 开头的文件 +- **THEN** 系统跳过这些隐藏文件 + +### Requirement: Dependency Sorting + +系统 SHALL 根据对象间依赖关系确定正确的执行顺序。 + +#### Scenario: Sort by dependency +- **WHEN** 对象之间存在依赖关系 +- **THEN** 被依赖的对象排在前面(如接口在类之前) + +#### Scenario: Detect cyclic dependency +- **WHEN** 对象间存在循环依赖 +- **THEN** 系统报告循环依赖错误并显示循环链 + +#### Scenario: Ignore external dependencies +- **WHEN** 对象依赖了不在当前项目中的外部对象 +- **THEN** 系统忽略该外部依赖,不报错 + +### Requirement: Batch Sync + +系统 SHALL 支持项目级批量同步操作。 + +#### Scenario: Sync all objects +- **WHEN** 用户执行 sync --all 命令 +- **THEN** 系统扫描项目、按依赖排序、逐个同步所有对象 + +#### Scenario: Dry run mode +- **WHEN** 用户执行 sync --all --dry-run +- **THEN** 系统仅显示将要同步的对象列表,不实际执行 + +#### Scenario: Fail fast mode +- **WHEN** 用户执行 sync --all --fail-fast +- **THEN** 任一对象同步失败时立即停止,不继续后续对象 + +### Requirement: Status Refresh + +系统 SHALL 刷新项目清单中所有对象在 SAP 系统中的状态。 + +#### Scenario: Refresh object status +- **WHEN** 用户执行 refresh 命令 +- **THEN** 系统查询每个对象在 SAP 中的最新状态并更新清单 diff --git a/openspec/specs/config/spec.md b/openspec/specs/config/spec.md new file mode 100644 index 0000000..acc5036 --- /dev/null +++ b/openspec/specs/config/spec.md @@ -0,0 +1,47 @@ +# Configuration Management Specification + +## Purpose + +定义 sap-cli 的配置管理功能行为,包括配置查看、设置和多 profile 管理。 + +## Requirements + +### Requirement: Configuration Display + +系统 SHALL 显示当前的 SAP 连接配置。 + +#### Scenario: Show current config +- **WHEN** 用户执行 config show 命令 +- **THEN** 系统显示当前生效的配置项(主机、客户端、用户名),密码以掩码显示 + +### Requirement: Configuration Setting + +系统 SHALL 支持通过命令行设置配置项。 + +#### Scenario: Set config value +- **WHEN** 用户执行 config set 命令并指定键和值 +- **THEN** 系统将配置值保存到配置文件中 + +### Requirement: Multi-Profile Management + +系统 SHALL 支持多 SAP 系统配置 profile。 + +#### Scenario: List profiles +- **WHEN** 用户执行 config list 命令 +- **THEN** 系统显示所有已配置的 profile 列表 + +#### Scenario: Use specific profile +- **WHEN** 用户通过 --profile 参数指定 profile +- **THEN** 系统使用该 profile 的配置连接对应的 SAP 系统 + +### Requirement: Secure Password Storage + +系统 SHALL 支持通过系统密钥环安全存储 SAP 密码。 + +#### Scenario: Save password to keyring +- **WHEN** 用户配置密码存储选项 +- **THEN** 密码保存到操作系统密钥环而非明文配置文件 + +#### Scenario: Load password from keyring +- **WHEN** 配置文件中未指定密码但密钥环中存在 +- **THEN** 系统从密钥环读取密码 diff --git a/openspec/specs/connection/spec.md b/openspec/specs/connection/spec.md new file mode 100644 index 0000000..e828263 --- /dev/null +++ b/openspec/specs/connection/spec.md @@ -0,0 +1,55 @@ +# Connection Specification + +## Purpose + +定义 sap-cli 与 SAP 系统建立连接、完成身份认证和会话管理的行为。 + +## Requirements + +### Requirement: User Authentication + +系统 SHALL 通过 SAP ADT REST API 使用 HTTP Basic Auth 进行身份认证。 + +#### Scenario: Successful login +- **WHEN** 用户提供有效的 SAP 主机地址、客户端编号、用户名和密码 +- **THEN** 系统打印 "登录成功" 并建立可用会话 + +#### Scenario: Login failure +- **WHEN** 用户提供了无效的 SAP 凭据 +- **THEN** 系统打印错误信息并以非零退出码退出 + +#### Scenario: Config not found +- **WHEN** 用户未提供配置文件且未设置环境变量 +- **THEN** 系统打印配置缺失提示,列出需要的环境变量名称,并以非零退出码退出 + +### Requirement: Configuration Loading + +系统 SHALL 支持 config.ini 文件和环境变量两种配置方式,环境变量优先于配置文件。 + +#### Scenario: Load from config file +- **WHEN** 用户运行命令且当前目录或指定路径存在有效的 config.ini +- **THEN** 系统从配置文件读取 SAP 连接参数 + +#### Scenario: Environment variable override +- **WHEN** 同时存在配置文件和环境变量 +- **THEN** 环境变量值覆盖配置文件中的对应项 + +#### Scenario: Profile selection +- **WHEN** 用户通过 --profile 参数指定配置 section +- **THEN** 系统从 config.ini 的对应 section 读取配置 + +#### Scenario: Client number default +- **WHEN** 配置中未指定 SAP 客户端编号 +- **THEN** 系统使用默认值 "100" + +### Requirement: Global Options + +系统 SHALL 支持全局命令行选项。 + +#### Scenario: Custom config path +- **WHEN** 用户通过 --config 指定配置文件路径 +- **THEN** 系统从指定路径加载配置 + +#### Scenario: No command specified +- **WHEN** 用户运行程序但未指定任何子命令 +- **THEN** 系统打印帮助信息并以退出码 1 退出 diff --git a/openspec/specs/object-lifecycle/spec.md b/openspec/specs/object-lifecycle/spec.md new file mode 100644 index 0000000..a1b849b --- /dev/null +++ b/openspec/specs/object-lifecycle/spec.md @@ -0,0 +1,186 @@ +# Object Lifecycle Specification + +## Purpose + +定义 sap-cli 对 SAP 开发对象的创建、读取(下载源码和信息查询)、更新(同步)、删除操作行为。 +涵盖所有支持的对象类型:report、class、interface、function、functiongroup、domain、dataelement、table、structure、tabletype、messageclass、view、searchhelp、lockobject、cdsview、include。 + +## Requirements + +### Requirement: Object Creation + +系统 SHALL 在 SAP 系统中创建开发对象,支持 16 种对象类型。 + +#### Scenario: Create with default template +- **WHEN** 用户执行 create 命令,指定对象名称和类型,未提供自定义源码 +- **THEN** 系统使用内置模板生成源码并在 SAP 中创建对象 + +#### Scenario: Create with custom source file +- **WHEN** 用户通过 --source 参数指定本地 .abap 文件 +- **THEN** 系统读取文件内容作为源码并在 SAP 中创建对象 + +#### Scenario: Create DDIC object with definition +- **WHEN** 用户通过 --definition 参数指定 .json 文件创建 DDIC 对象(domain、dataelement、table、structure、tabletype) +- **THEN** 系统将 JSON 定义转换为 SAP 所需格式并创建对象 + +#### Scenario: Create message class +- **WHEN** 用户执行 create 命令,指定类型为 messageclass,并提供名称和描述 +- **THEN** 系统在 SAP 中创建消息类对象 + +#### Scenario: Create database view +- **WHEN** 用户执行 create 命令,指定类型为 view,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建数据库视图 + +#### Scenario: Create search help +- **WHEN** 用户执行 create 命令,指定类型为 searchhelp,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建搜索帮助 + +#### Scenario: Create lock object +- **WHEN** 用户执行 create 命令,指定类型为 lockobject,并提供 JSON 定义文件 +- **THEN** 系统将 JSON 转换为 DDIC XML 并在 SAP 中创建锁对象 + +#### Scenario: Create already existing object +- **WHEN** SAP 系统中已存在同名同类型对象 +- **THEN** 系统报告对象已存在的错误 + +#### Scenario: Create with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 创建的对象绑定到指定的传输请求 + +### Requirement: Source Code Download + +系统 SHALL 从 SAP 系统下载对象源代码并保存到本地文件。 + +#### Scenario: Download existing object +- **WHEN** 用户执行 download 命令,SAP 系统存在该对象 +- **THEN** 系统下载源码并保存到指定目录,文件名为小写对象名+.abap + +#### Scenario: Download non-existent object +- **WHEN** 用户执行 download 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Download function type +- **WHEN** 用户下载 function 类型对象,名称格式为 "组名/模块名" +- **THEN** 系统正确下载函数模块源码 + +#### Scenario: Download type without source +- **WHEN** 用户尝试下载不支持源码的类型(如 functiongroup、messageclass、lockobject) +- **THEN** 系统报告该类型不支持下载操作 + +#### Scenario: Download database view source +- **WHEN** 用户下载 view 类型对象 +- **THEN** 系统下载视图的 DDL 定义源码 + +### Requirement: Source Code Sync + +系统 SHALL 将本地源代码同步到 SAP 系统,执行完整的锁定→写入→解锁→检查→激活流程。 + +#### Scenario: Full sync success +- **WHEN** 用户执行 sync 命令,本地文件和 SAP 对象均有效 +- **THEN** 系统依次执行锁定、写入、解锁、语法检查、激活,每步报告进度 + +#### Scenario: Auto-create on sync +- **WHEN** 用户同步一个 SAP 中不存在的对象 +- **THEN** 系统自动创建空对象后继续同步流程 + +#### Scenario: Syntax check failure +- **WHEN** 源码包含语法错误 +- **THEN** 系统报告语法错误详情(行号和描述) + +#### Scenario: Activation failure +- **WHEN** 语法检查通过但激活失败 +- **THEN** 系统报告激活错误详情 + +#### Scenario: Sync with specified transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统在整个同步流程中使用该传输请求 + +### Requirement: Object Information Query + +系统 SHALL 查询 SAP 系统中所有支持类型的开发对象元数据。 + +#### Scenario: Query existing object +- **WHEN** 用户执行 info 命令,SAP 系统存在该对象 +- **THEN** 系统显示对象的名称、类型、描述、激活状态、负责人等元数据 + +#### Scenario: Query non-existent object +- **WHEN** 用户执行 info 命令,SAP 系统不存在该对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Query message class info +- **WHEN** 用户执行 info 命令查询消息类 +- **THEN** 系统显示消息类的名称、描述、消息数量等元数据 + +#### Scenario: Query view info +- **WHEN** 用户执行 info 命令查询数据库视图 +- **THEN** 系统显示视图的名称、描述、关联表等元数据 + +### Requirement: Object Deletion + +系统 SHALL 从 SAP 系统中删除所有支持类型的开发对象。 + +#### Scenario: Delete with confirmation +- **WHEN** 用户执行 delete 命令并确认删除操作 +- **THEN** 系统锁定、删除对象并报告成功 + +#### Scenario: Delete cancelled +- **WHEN** 用户执行 delete 命令但未确认删除 +- **THEN** 系统取消操作,不执行删除 + +#### Scenario: Delete non-existent object +- **WHEN** 用户尝试删除 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Delete message class +- **WHEN** 用户执行 delete 命令删除消息类并确认 +- **THEN** 系统删除该消息类 + +#### Scenario: Delete lock object +- **WHEN** 用户执行 delete 命令删除锁对象并确认 +- **THEN** 系统删除该锁对象及其自动生成的函数模块 + +### Requirement: Source Code Upload + +系统 SHALL 上传本地源代码到 SAP 系统,执行锁定→写入→解锁流程,不执行语法检查和激活。 +upload 不会自动创建对象——上传前对象必须已存在于 SAP 系统中。 + +#### Scenario: Upload success +- **WHEN** 用户执行 upload 命令,对象已存在于 SAP,本地文件有效 +- **THEN** 系统执行锁定、写入源码、解锁,报告写入的字符数和行数,不执行语法检查和激活 + +#### Scenario: Upload non-existent object +- **WHEN** 用户上传一个 SAP 中不存在的对象 +- **THEN** 系统报告对象不存在的错误(不自动创建,区别于 sync) + +#### Scenario: Upload with transport request +- **WHEN** 用户通过 --corr_nr 指定传输请求号 +- **THEN** 系统使用该传输请求号进行锁定和写入 + +#### Scenario: Upload type without source +- **WHEN** 用户尝试上传不支持源码的类型(如 functiongroup、tabletype、messageclass、searchhelp、lockobject) +- **THEN** 系统报告该类型不支持上传操作 + +#### Scenario: Upload local file not found +- **WHEN** 用户指定的本地源码文件路径不存在 +- **THEN** 系统报告文件不存在的错误 + +### Requirement: Syntax Check + +系统 SHALL 对 SAP 远程对象的源代码执行语法检查,不上传代码、不激活。 +syntax-check 检查 SAP 上现有对象的源码(通常是 active 状态),用于验证结构变更后的依赖影响。 + +#### Scenario: Syntax check passed +- **WHEN** 用户执行 syntax-check 命令,对象的源码无语法错误 +- **THEN** 系统报告语法检查通过 + +#### Scenario: Syntax check failed +- **WHEN** 对象源码包含语法错误 +- **THEN** 系统报告语法检查未通过,显示每个错误的行号和描述 + +#### Scenario: Syntax check non-existent object +- **WHEN** 用户对 SAP 中不存在的对象执行语法检查 +- **THEN** 系统报告对象不存在的错误 + +#### Scenario: Syntax check type without source +- **WHEN** 用户对不支持源码的类型(如 functiongroup、tabletype)执行语法检查 +- **THEN** 系统报告该类型不支持语法检查操作 diff --git a/openspec/specs/quality/spec.md b/openspec/specs/quality/spec.md new file mode 100644 index 0000000..8f30235 --- /dev/null +++ b/openspec/specs/quality/spec.md @@ -0,0 +1,47 @@ +# Code Quality Specification + +## Purpose + +定义 sap-cli 的代码质量相关功能行为,包括 ATC 检查和代码格式化。 + +## Requirements + +### Requirement: ATC Code Check + +系统 SHALL 对 SAP 开发对象执行 ABAP Test Cockpit (ATC) 代码检查。 + +#### Scenario: Check with findings +- **WHEN** 用户执行 check 命令并指定对象名称和类型 +- **THEN** 系统显示 ATC 检查发现的问题列表(优先级、位置、描述) + +#### Scenario: Check clean +- **WHEN** ATC 检查未发现任何问题 +- **THEN** 系统显示检查通过的信息 + +### Requirement: Code Formatting + +系统 SHALL 对 SAP 开发对象执行 ABAP Pretty Printer 格式化。 + +#### Scenario: Format source code +- **WHEN** 用户执行 format 命令并指定对象名称和类型 +- **THEN** 系统对 SAP 中的源码执行格式化并报告结果 + +### Requirement: Dependency Analysis + +系统 SHALL 自动分析项目中对象的依赖关系。 + +#### Scenario: Analyze project dependencies +- **WHEN** 用户执行 analyze 命令并指定项目路径 +- **THEN** 系统扫描源码中的依赖引用并生成依赖报告 + +### Requirement: Project Scaffolding + +系统 SHALL 根据预定义模板创建 ABAP 项目结构。 + +#### Scenario: Scaffold from template +- **WHEN** 用户执行 scaffold 命令并指定名称和模板类型 +- **THEN** 系统基于模板生成包含源码文件的项目结构 + +#### Scenario: List available templates +- **WHEN** 用户执行 scaffold 命令但未指定模板 +- **THEN** 系统显示可用的模板列表 diff --git a/openspec/specs/search-browse/spec.md b/openspec/specs/search-browse/spec.md new file mode 100644 index 0000000..9c3c6b7 --- /dev/null +++ b/openspec/specs/search-browse/spec.md @@ -0,0 +1,79 @@ +# Search and Browse Specification + +## Purpose + +定义 sap-cli 的搜索和浏览功能行为,包括对象列表、引用查询和源码搜索。 + +## Requirements + +### Requirement: Object Listing + +系统 SHALL 列出 SAP 系统中指定类型的开发对象。 + +#### Scenario: List by type with prefix +- **WHEN** 用户执行 list 命令并指定对象类型和前缀筛选 +- **THEN** 系统显示匹配的对象名称列表 + +#### Scenario: List without filter +- **WHEN** 用户执行 list 命令但未指定筛选条件 +- **THEN** 系统显示该类型的所有可访问对象 + +### Requirement: Where-Used Query + +系统 SHALL 查询指定对象在其他开发对象中的引用关系。 + +#### Scenario: Find references +- **WHEN** 用户执行 whereused 命令并指定对象名称和类型 +- **THEN** 系统显示所有引用该对象的开发对象列表 + +#### Scenario: No references found +- **WHEN** 指定对象没有被任何其他对象引用 +- **THEN** 系统显示无引用结果的信息 + +### Requirement: Source Code Search + +系统 SHALL 在 SAP 系统中搜索源代码内容。 + +#### Scenario: Search by keyword +- **WHEN** 用户执行 search 命令并提供搜索关键词 +- **THEN** 系统显示包含该关键词的对象名称、类型和匹配行 + +#### Scenario: Search no results +- **WHEN** 搜索关键词没有匹配结果 +- **THEN** 系统显示无搜索结果的信息 + +### Requirement: Code Diff + +系统 SHALL 对比本地源码与 SAP 系统中当前版本的差异。 + +#### Scenario: Diff with differences +- **WHEN** 用户执行 diff 命令,本地文件与 SAP 版本存在差异 +- **THEN** 系统显示差异内容(新增、删除、修改的行) + +#### Scenario: Diff identical +- **WHEN** 本地文件与 SAP 版本完全一致 +- **THEN** 系统显示无差异的信息 + +### Requirement: CDS View Operations + +系统 SHALL 支持 CDS View 的下载和创建操作。 + +#### Scenario: Download CDS view +- **WHEN** 用户执行 cds download 命令并指定 CDS View 名称 +- **THEN** 系统下载 CDS View 定义并保存到本地 + +#### Scenario: Create CDS view +- **WHEN** 用户执行 cds create 命令并指定名称和描述 +- **THEN** 系统在 SAP 中创建新的 CDS View + +### Requirement: Package Operations + +系统 SHALL 支持 ABAP 包的查询和创建操作。 + +#### Scenario: Query package info +- **WHEN** 用户执行 package info 命令并指定包名 +- **THEN** 系统显示包的描述、包含的对象数量等信息 + +#### Scenario: Create package +- **WHEN** 用户执行 package create 命令并指定包名和描述 +- **THEN** 系统在 SAP 中创建新的 ABAP 包 diff --git a/openspec/specs/transport/spec.md b/openspec/specs/transport/spec.md new file mode 100644 index 0000000..015f89b --- /dev/null +++ b/openspec/specs/transport/spec.md @@ -0,0 +1,39 @@ +# Transport Management Specification + +## Purpose + +定义 sap-cli 的 SAP 传输请求管理功能行为。 + +## Requirements + +### Requirement: Transport Request Listing + +系统 SHALL 列出 SAP 系统中的传输请求。 + +#### Scenario: List transport requests +- **WHEN** 用户执行 transport list 命令 +- **THEN** 系统显示当前用户的传输请求列表(请求号、描述、状态) + +### Requirement: Transport Request Info + +系统 SHALL 查询指定传输请求的详细信息。 + +#### Scenario: Query transport info +- **WHEN** 用户执行 transport info 命令并指定传输请求号 +- **THEN** 系统显示传输请求的所有者、状态、包含的对象数量等信息 + +### Requirement: Transport Request Release + +系统 SHALL 释放传输请求。 + +#### Scenario: Release transport request +- **WHEN** 用户执行 transport release 命令并指定传输请求号 +- **THEN** 系统释放该传输请求 + +### Requirement: Transport Objects Listing + +系统 SHALL 列出传输请求中包含的对象。 + +#### Scenario: List objects in transport +- **WHEN** 用户执行 transport objects 命令并指定传输请求号 +- **THEN** 系统显示该传输请求中包含的所有开发对象列表 diff --git a/references/sap-tool-constraints.md b/references/sap-tool-constraints.md index 9878025..53e5255 100644 --- a/references/sap-tool-constraints.md +++ b/references/sap-tool-constraints.md @@ -20,10 +20,10 @@ **唯一合法的 SAP 交互方式**: ```bash -cd D:/Gitee/sap-cli && python main.py [options] +cd D:/Gitee/sap-cli-skill/assets && python main.py [options] ``` -> 本机运行时目录为 `D:/Gitee/sap-cli`(`D:/Codespace/sap-cli` 不存在)。通过 `--profile <名>` 切换目标系统,连接配置在 `config.ini`。 +> 本机唯一源目录为 `D:/Gitee/sap-cli-skill`,工具在其 `assets/` 下(原 `D:/Gitee/sap-cli` 已归档,勿再用)。通过 `--profile <名>` 切换目标系统,连接配置在 `assets/config.ini`。 ### 例外(仅在以下情况下允许) diff --git a/tests/e2e_project/classes/zcl_sapilot_constants.abap b/tests/e2e_project/classes/zcl_sapilot_constants.abap new file mode 100644 index 0000000..78ee9aa --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_constants.abap @@ -0,0 +1,98 @@ +CLASS zcl_sapilot_constants DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + CONSTANTS: + gc_objtype_rfc TYPE string VALUE 'RFC', + gc_objtype_cds TYPE string VALUE 'CDS', + gc_objtype_odata TYPE string VALUE 'ODATA', + gc_objtype_clas TYPE string VALUE 'CLAS'. + + CONSTANTS: + gc_op_get_list TYPE string VALUE 'GET_LIST', + gc_op_get_detail TYPE string VALUE 'GET_DETAIL', + gc_op_use TYPE string VALUE 'USE', + gc_op_health TYPE string VALUE 'HEALTH', + gc_op_docs TYPE string VALUE 'DOCS', + gc_op_logs TYPE string VALUE 'LOGS', + gc_op_admin_reg TYPE string VALUE 'ADMIN_REG', + gc_op_admin_upd TYPE string VALUE 'ADMIN_UPD', + gc_op_admin_del TYPE string VALUE 'ADMIN_DEL', + gc_op_admin_act TYPE string VALUE 'ADMIN_ACT'. + + CONSTANTS: + gc_http_get TYPE string VALUE 'GET', + gc_http_post TYPE string VALUE 'POST', + gc_http_put TYPE string VALUE 'PUT', + gc_http_delete TYPE string VALUE 'DELETE'. + + CONSTANTS: + gc_sicf_path TYPE string VALUE '/sap/bc/sapilot', + gc_content_type TYPE string VALUE 'application/json', + gc_charset TYPE string VALUE 'utf-8'. + + CONSTANTS: + gc_field_success TYPE string VALUE 'success', + gc_field_data TYPE string VALUE 'data', + gc_field_error TYPE string VALUE 'error', + gc_field_metadata TYPE string VALUE 'metadata', + gc_field_request_id TYPE string VALUE 'requestId', + gc_field_duration TYPE string VALUE 'duration', + gc_field_timestamp TYPE string VALUE 'timestamp'. + + CONSTANTS: + gc_default_timeout_ms TYPE i VALUE 30000, + gc_default_limit TYPE i VALUE 50, + gc_max_limit TYPE i VALUE 200. + + CONSTANTS: + gc_dir_import TYPE string VALUE 'IMPORT', + gc_dir_export TYPE string VALUE 'EXPORT', + gc_dir_changing TYPE string VALUE 'CHANGING', + gc_dir_tables TYPE string VALUE 'TABLES'. + + CONSTANTS: + gc_status_success TYPE string VALUE 'success', + gc_status_error TYPE string VALUE 'error'. + + TYPES: + BEGIN OF ty_route, + http_method TYPE string, + path_pattern TYPE string, + operation TYPE string, + END OF ty_route, + tt_routes TYPE STANDARD TABLE OF ty_route WITH EMPTY KEY. + + TYPES: + BEGIN OF ty_list_params, + type TYPE string, + category TYPE string, + keyword TYPE string, + limit TYPE i, + offset TYPE i, + END OF ty_list_params. + + TYPES: + BEGIN OF ty_response, + success TYPE abap_bool, + data TYPE REF TO data, + error TYPE string, + metadata TYPE REF TO data, + END OF ty_response. + + TYPES: + BEGIN OF ty_object_item, + name TYPE string, + type TYPE string, + category TYPE string, + description TYPE string, + END OF ty_object_item, + tt_object_items TYPE STANDARD TABLE OF ty_object_item WITH EMPTY KEY. + +ENDCLASS. + + +CLASS zcl_sapilot_constants IMPLEMENTATION. +ENDCLASS. \ No newline at end of file diff --git a/tests/e2e_project/classes/zcl_sapilot_http_handler.abap b/tests/e2e_project/classes/zcl_sapilot_http_handler.abap new file mode 100644 index 0000000..8cb36eb --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_http_handler.abap @@ -0,0 +1,205 @@ +CLASS zcl_sapilot_http_handler DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + INTERFACES if_http_extension. + + PRIVATE SECTION. + METHODS process_request + IMPORTING io_server TYPE REF TO if_http_server + RAISING zcx_sapilot_error. + + METHODS set_response_headers + IMPORTING io_server TYPE REF TO if_http_server. + + METHODS send_error_response + IMPORTING io_server TYPE REF TO if_http_server + ix_error TYPE REF TO zcx_sapilot_error. + + METHODS send_success_response + IMPORTING io_server TYPE REF TO if_http_server + iv_json TYPE string + iv_status TYPE i DEFAULT 200. + + METHODS get_client_ip + IMPORTING io_server TYPE REF TO if_http_server + RETURNING VALUE(rv_ip) TYPE string. + +ENDCLASS. + + +CLASS zcl_sapilot_http_handler IMPLEMENTATION. + + METHOD if_http_extension~handle_request. + DATA: lx_error TYPE REF TO zcx_sapilot_error. + DATA: lx_unexpected TYPE REF TO cx_static_check. + DATA: lo_unexpected TYPE REF TO zcx_sapilot_error. + + TRY. + process_request( server ). + + CATCH zcx_sapilot_error INTO lx_error. + send_error_response( io_server = server ix_error = lx_error ). + + CATCH cx_static_check INTO lx_unexpected. + CREATE OBJECT lo_unexpected + EXPORTING + iv_http_status = 500 + iv_error_code = 'INTERNAL_ERROR' + iv_message = 'An unexpected error occurred' + iv_details = lx_unexpected->get_text( ). + send_error_response( io_server = server ix_error = lo_unexpected ). + ENDTRY. + ENDMETHOD. + + METHOD process_request. + DATA: lo_router TYPE REF TO zcl_sapilot_router, + lo_response_builder TYPE REF TO zcl_sapilot_response, + lo_audit TYPE REF TO zcl_sapilot_audit_logger, + lo_health TYPE REF TO zcl_sapilot_op_health, + lo_getlist TYPE REF TO zcl_sapilot_op_getlist, + lo_getdetail TYPE REF TO zcl_sapilot_op_getdetail, + lo_use TYPE REF TO zcl_sapilot_op_use, + lo_logs TYPE REF TO zcl_sapilot_op_logs, + lo_admin TYPE REF TO zcl_sapilot_op_admin. + + CREATE OBJECT lo_router. + CREATE OBJECT lo_response_builder. + CREATE OBJECT lo_audit. + + GET TIME STAMP FIELD DATA(lv_start_ts). + DATA(lv_request_id) = lo_response_builder->generate_request_id( ). + + DATA(lv_method) = CONV string( io_server->request->get_method( ) ). + DATA(lv_path) = CONV string( io_server->request->get_header_field( '~path_info' ) ). + DATA(lv_query) = CONV string( io_server->request->get_header_field( '~query_string' ) ). + DATA(lv_body) = io_server->request->get_cdata( ). + + DATA(ls_route) = lo_router->route( iv_method = lv_method iv_path = lv_path ). + + DATA: lv_result_json TYPE string. + DATA: lx_route TYPE REF TO zcx_sapilot_error. + + CASE ls_route-operation. + WHEN zcl_sapilot_constants=>gc_op_health. + CREATE OBJECT lo_health. + lv_result_json = lo_health->execute( ). + WHEN zcl_sapilot_constants=>gc_op_get_list. + CREATE OBJECT lo_getlist. + lv_result_json = lo_getlist->execute( iv_query_string = lv_query ). + WHEN zcl_sapilot_constants=>gc_op_get_detail. + CREATE OBJECT lo_getdetail. + lv_result_json = lo_getdetail->execute( iv_path_info = lv_path ). + WHEN zcl_sapilot_constants=>gc_op_use. + CREATE OBJECT lo_use. + lv_result_json = lo_use->execute( iv_path_info = lv_path iv_request_body = lv_body ). + WHEN zcl_sapilot_constants=>gc_op_logs. + CREATE OBJECT lo_logs. + lv_result_json = lo_logs->execute( iv_query_string = lv_query ). + WHEN zcl_sapilot_constants=>gc_op_admin_reg OR + zcl_sapilot_constants=>gc_op_admin_upd OR + zcl_sapilot_constants=>gc_op_admin_del OR + zcl_sapilot_constants=>gc_op_admin_act. + CREATE OBJECT lo_admin. + lv_result_json = lo_admin->execute( + iv_operation = ls_route-operation + iv_object_type = CONV string( ls_route-object_type ) + iv_object_name = CONV string( ls_route-object_name ) + iv_request_body = lv_body ). + WHEN OTHERS. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 404 + iv_error_code = 'ROUTE_NOT_FOUND' + iv_message = |No handler for path: { lv_path }|. + ENDCASE. + + GET TIME STAMP FIELD DATA(lv_end_ts). + DATA: lv_duration_ms TYPE i. + lv_duration_ms = cl_abap_tstmp=>subtract( tstmp1 = lv_end_ts tstmp2 = lv_start_ts ). + + DATA(lv_response) = lo_response_builder->build_success( + iv_json = lv_result_json + iv_request_id = lv_request_id + iv_duration = |{ lv_duration_ms }ms| ). + + TRY. + lo_audit->log( + iv_request_id = lv_request_id + iv_operation = ls_route-operation + iv_object_type = CONV string( ls_route-object_type ) + iv_object_name = CONV string( ls_route-object_name ) + iv_input_json = lv_body + iv_status = zcl_sapilot_constants=>gc_status_success + iv_duration_ms = lv_duration_ms + iv_remote_ip = get_client_ip( io_server ) ). + CATCH cx_static_check. + ENDTRY. + + send_success_response( io_server = io_server iv_json = lv_response ). + + ENDMETHOD. + + METHOD set_response_headers. + DATA(lo_response) = io_server->response. + lo_response->set_content_type( zcl_sapilot_constants=>gc_content_type ). + lo_response->set_header_field( name = 'charset' value = zcl_sapilot_constants=>gc_charset ). + lo_response->set_header_field( name = 'Access-Control-Allow-Origin' value = '*' ). + lo_response->set_header_field( name = 'Access-Control-Allow-Methods' value = 'GET, POST, PUT, DELETE, OPTIONS' ). + lo_response->set_header_field( name = 'Access-Control-Allow-Headers' value = 'Content-Type, Authorization' ). + ENDMETHOD. + + METHOD send_error_response. + DATA: lo_response_builder TYPE REF TO zcl_sapilot_response. + DATA: lv_request_id TYPE string. + DATA: lv_duration TYPE string. + DATA: lv_json TYPE string. + DATA: lv_error_json TYPE string. + DATA: lv_metadata TYPE string. + + CREATE OBJECT lo_response_builder. + lv_request_id = lo_response_builder->generate_request_id( ). + lv_duration = '0ms'. + lv_error_json = ix_error->get_json_error( ). + lv_metadata = lo_response_builder->build_metadata( iv_request_id = lv_request_id iv_duration = lv_duration ). + + DATA: lv_open TYPE c LENGTH 1, + lv_close TYPE string. + lv_open = cl_abap_char_utilities=>horizontal_tab. + FIELD-SYMBOLS: TYPE x. + ASSIGN lv_open TO CASTING. + = '7B'. + lv_close = '}'. + + CONCATENATE lv_open '"success": false, "error": ' lv_error_json ', "metadata": ' lv_metadata lv_close INTO lv_json. + + set_response_headers( io_server ). + io_server->response->set_status( code = ix_error->mv_http_status reason = ix_error->mv_error_code ). + io_server->response->set_cdata( lv_json ). + ENDMETHOD. + + METHOD send_success_response. + set_response_headers( io_server ). + io_server->response->set_status( code = iv_status reason = 'OK' ). + io_server->response->set_cdata( iv_json ). + ENDMETHOD. + + METHOD get_client_ip. + DATA: lv_fwd TYPE string, + lv_real TYPE string. + lv_fwd = io_server->request->get_header_field( 'X-Forwarded-For' ). + IF lv_fwd IS NOT INITIAL. + rv_ip = lv_fwd. + ELSE. + lv_real = io_server->request->get_header_field( 'X-Real-IP' ). + IF lv_real IS NOT INITIAL. + rv_ip = lv_real. + ELSE. + rv_ip = io_server->request->get_header_field( 'REMOTE_ADDR' ). + ENDIF. + ENDIF. + ENDMETHOD. + +ENDCLASS. diff --git a/tests/e2e_project/classes/zcl_sapilot_op_admin.abap b/tests/e2e_project/classes/zcl_sapilot_op_admin.abap new file mode 100644 index 0000000..28a6a9e --- /dev/null +++ b/tests/e2e_project/classes/zcl_sapilot_op_admin.abap @@ -0,0 +1,382 @@ +"! SAPilot Operation: admin +"! Manages the object whitelist registry via REST API. +"! +"! Endpoints: +"! POST /admin/objects — Register new object +"! POST /admin/objects/{type}/{name} — Update object metadata +"! DELETE /admin/objects/{type}/{name} — Delete object from registry +"! POST /admin/objects/{type}/{name}/active — Toggle active status +"! +"! All operations require SAP administrator authority. +CLASS zcl_sapilot_op_admin DEFINITION + PUBLIC + FINAL + CREATE PUBLIC. + + PUBLIC SECTION. + METHODS execute + IMPORTING iv_operation TYPE string + iv_object_type TYPE string OPTIONAL + iv_object_name TYPE string OPTIONAL + iv_request_body TYPE string OPTIONAL + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + PRIVATE SECTION. + METHODS check_admin_authority + RAISING zcx_sapilot_error. + + METHODS register_object + IMPORTING iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS update_object + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS delete_object + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS toggle_active + IMPORTING iv_object_type TYPE string + iv_object_name TYPE string + iv_request_body TYPE string + RETURNING VALUE(rv_json) TYPE string + RAISING zcx_sapilot_error. + + METHODS extract_json_field + IMPORTING iv_json TYPE string + iv_field TYPE string + RETURNING VALUE(rv_value) TYPE string. + + METHODS build_result_json + IMPORTING iv_action TYPE string + iv_type TYPE string + iv_name TYPE string + RETURNING VALUE(rv_json) TYPE string. + +ENDCLASS. + + +CLASS zcl_sapilot_op_admin IMPLEMENTATION. + + METHOD execute. + " ----------------------------------------------------------------- + " Admin operation dispatcher + " ----------------------------------------------------------------- + check_admin_authority( ). + + CASE iv_operation. + WHEN zcl_sapilot_constants=>gc_op_admin_reg. + rv_json = register_object( iv_request_body = iv_request_body ). + + WHEN zcl_sapilot_constants=>gc_op_admin_upd. + rv_json = update_object( + iv_object_type = iv_object_type + iv_object_name = iv_object_name + iv_request_body = iv_request_body ). + + WHEN zcl_sapilot_constants=>gc_op_admin_del. + rv_json = delete_object( + iv_object_type = iv_object_type + iv_object_name = iv_object_name ). + + WHEN zcl_sapilot_constants=>gc_op_admin_act. + rv_json = toggle_active( + iv_object_type = iv_object_type + iv_object_name = iv_object_name + iv_request_body = iv_request_body ). + + WHEN OTHERS. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 400 + iv_error_code = 'UNKNOWN_ADMIN_OPERATION' + iv_message = |Unknown admin operation: { iv_operation }|. + ENDCASE. + ENDMETHOD. + + METHOD check_admin_authority. + " ----------------------------------------------------------------- + " Verify the current user has SAP development/admin authority + " Check S_DEVELOP with object type 'ZS' (custom namespace) + " ----------------------------------------------------------------- + AUTHORITY-CHECK OBJECT 'S_DEVELOP' + ID 'DEVCLASS' DUMMY + ID 'OBJTYPE' FIELD 'ZS' + ID 'OBJNAME' DUMMY + ID 'P_GROUP' DUMMY + ID 'ACTVT' FIELD '02'. + + IF sy-subrc <> 0. + " Fallback: check if user has S_RFC with * + AUTHORITY-CHECK OBJECT 'S_RFC' + ID 'RFC_TYPE' FIELD '*' + ID 'RFC_NAME' FIELD '*'. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING + iv_http_status = 403 + iv_error_code = 'ADMIN_ACCESS_DENIED' + iv_message = |User { sy-uname } does not have admin authority|. + ENDIF. + ENDIF. + ENDMETHOD. + + METHOD register_object. + " ----------------------------------------------------------------- + " POST /admin/objects + " Body: {"type": "RFC", "name": "BAPI_USER_GET_DETAIL", "category": "...", "description": "..."} + " Actual ZSAPILOT_OBJ fields: object_type, object_name, category, + " description, http_method, status + " ----------------------------------------------------------------- + IF iv_request_body IS INITIAL. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'EMPTY_BODY' + iv_message = |Request body is required. Expected: \{"type", "name"\}|. + ENDIF. + + DATA(lv_type) = extract_json_field( iv_json = iv_request_body iv_field = 'type' ). + DATA(lv_name) = extract_json_field( iv_json = iv_request_body iv_field = 'name' ). + + IF lv_type IS INITIAL OR lv_name IS INITIAL. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'MISSING_REQUIRED_FIELD' + iv_message = |Both 'type' and 'name' are required|. + ENDIF. + + lv_type = to_upper( lv_type ). + lv_name = to_upper( lv_name ). + + " Validate object type + IF lv_type <> 'RFC' AND lv_type <> 'CDS' AND lv_type <> 'ODATA' AND lv_type <> 'CLAS'. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 400 iv_error_code = 'INVALID_OBJECT_TYPE' + iv_message = |Object type '{ lv_type }' is not supported. Use: RFC, CDS, ODATA, CLAS|. + ENDIF. + + " Check if already registered (old OpenSQL for NW 7.40) + DATA: lv_exists TYPE zsapilot_object_name. + SELECT SINGLE object_name FROM zsapilot_obj + WHERE object_type = lv_type AND object_name = lv_name + INTO lv_exists. + + IF sy-subrc = 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 409 iv_error_code = 'OBJECT_ALREADY_REGISTERED' + iv_message = |Object { lv_type }/{ lv_name } is already registered|. + ENDIF. + + " Build registry entry + DATA: ls_registry TYPE zsapilot_obj. + ls_registry-object_type = lv_type. + ls_registry-object_name = lv_name. + ls_registry-category = extract_json_field( iv_json = iv_request_body iv_field = 'category' ). + ls_registry-description = extract_json_field( iv_json = iv_request_body iv_field = 'description' ). + ls_registry-status = 'ACTIVE'. + + " Determine default HTTP method based on type + CASE lv_type. + WHEN 'RFC' OR 'CLAS'. + ls_registry-http_method = 'POST'. + WHEN OTHERS. + ls_registry-http_method = 'GET'. + ENDCASE. + + INSERT zsapilot_obj FROM ls_registry. + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'REGISTER_FAILED' + iv_message = |Failed to register object { lv_type }/{ lv_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'registered' iv_type = lv_type iv_name = lv_name ). + ENDMETHOD. + + METHOD update_object. + " ----------------------------------------------------------------- + " POST /admin/objects/{type}/{name} (acting as PUT) + " Body: {"category": "...", "description": "..."} + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + " Verify object exists (old OpenSQL) + DATA: ls_registry TYPE zsapilot_obj. + SELECT SINGLE * FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO ls_registry. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Update fields from request body + IF iv_request_body IS NOT INITIAL. + DATA(lv_category) = extract_json_field( iv_json = iv_request_body iv_field = 'category' ). + DATA(lv_description) = extract_json_field( iv_json = iv_request_body iv_field = 'description' ). + + IF lv_category IS NOT INITIAL. + ls_registry-category = lv_category. + ENDIF. + IF lv_description IS NOT INITIAL. + ls_registry-description = lv_description. + ENDIF. + ENDIF. + + UPDATE zsapilot_obj FROM ls_registry. + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'UPDATE_FAILED' + iv_message = |Failed to update object { iv_object_type }/{ iv_object_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'updated' iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD delete_object. + " ----------------------------------------------------------------- + " DELETE /admin/objects/{type}/{name} + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + DATA: lv_exists TYPE zsapilot_object_name. + SELECT SINGLE object_name FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO lv_exists. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Delete associated field records first (old OpenSQL) + DELETE FROM zsapilot_objf + WHERE object_type = lv_otype AND object_name = lv_oname. + + " Delete the main record + DELETE FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 500 iv_error_code = 'DELETE_FAILED' + iv_message = |Failed to delete object { iv_object_type }/{ iv_object_name }|. + ENDIF. + + rv_json = build_result_json( iv_action = 'deleted' iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD toggle_active. + " ----------------------------------------------------------------- + " POST /admin/objects/{type}/{name}/active + " Body: {"active": true} or {"active": false} + " Uses 'status' field: 'ACTIVE' or 'INACTIVE' + " ----------------------------------------------------------------- + DATA: lv_otype TYPE zsapilot_object_type_de, + lv_oname TYPE zsapilot_object_name. + lv_otype = iv_object_type. + lv_oname = iv_object_name. + + DATA: ls_registry TYPE zsapilot_obj. + SELECT SINGLE * FROM zsapilot_obj + WHERE object_type = lv_otype AND object_name = lv_oname + INTO ls_registry. + + IF sy-subrc <> 0. + RAISE EXCEPTION TYPE zcx_sapilot_error + EXPORTING iv_http_status = 404 iv_error_code = 'OBJECT_NOT_FOUND' + iv_message = |Object { iv_object_type }/{ iv_object_name } not found|. + ENDIF. + + " Parse active flag from body + IF iv_request_body IS NOT INITIAL. + DATA(lv_active) = extract_json_field( iv_json = iv_request_body iv_field = 'active' ). + IF lv_active = 'true' OR lv_active = 'X'. + ls_registry-status = 'ACTIVE'. + ELSE. + ls_registry-status = 'INACTIVE'. + ENDIF. + ELSE. + " Toggle: if currently active, deactivate; if inactive, activate + IF ls_registry-status = 'ACTIVE'. + ls_registry-status = 'INACTIVE'. + ELSE. + ls_registry-status = 'ACTIVE'. + ENDIF. + ENDIF. + + UPDATE zsapilot_obj FROM ls_registry. + + DATA: lv_action TYPE string. + IF ls_registry-status = 'ACTIVE'. + lv_action = 'activated'. + ELSE. + lv_action = 'deactivated'. + ENDIF. + rv_json = build_result_json( iv_action = lv_action iv_type = iv_object_type iv_name = iv_object_name ). + ENDMETHOD. + + METHOD extract_json_field. + " ----------------------------------------------------------------- + " Simple JSON field extractor: find "field": "value" or "field": value + " Uses CONCATENATE to avoid backslash in string templates (NW 7.40) + " ----------------------------------------------------------------- + IF iv_json IS INITIAL OR iv_field IS INITIAL. + RETURN. + ENDIF. + + DATA: lv_bs TYPE string. + FIELD-SYMBOLS: TYPE x. + DATA: lv_bs_c TYPE c LENGTH 1. + lv_bs_c = cl_abap_char_utilities=>horizontal_tab. + ASSIGN lv_bs_c TO CASTING. + = '5C'. + lv_bs = lv_bs_c. + + " Pattern: "field": "value" + DATA: lv_ws TYPE string. + lv_ws = lv_bs && 's' && lv_bs && '*'. + DATA(lv_field_l) = to_lower( iv_field ). + CONCATENATE '"' lv_field_l '"' lv_ws ':' lv_ws '*"([^"]*)"' + INTO DATA(lv_pattern). + FIND REGEX lv_pattern IN to_lower( iv_json ) SUBMATCHES DATA(lv_val). + IF sy-subrc = 0 AND lv_val IS NOT INITIAL. + rv_value = lv_val. + RETURN. + ENDIF. + + " Pattern: "field": value (unquoted: true, false, number) + CONCATENATE '"' lv_field_l '"' lv_ws ':' lv_ws '*([a-z0-9]+)' + INTO lv_pattern. + FIND REGEX lv_pattern IN to_lower( iv_json ) SUBMATCHES lv_val. + IF sy-subrc = 0. + rv_value = lv_val. + ENDIF. + ENDMETHOD. + + METHOD build_result_json. + " ----------------------------------------------------------------- + " Build a simple result JSON: {"action": "...", "type": "...", "name": "..."} + " ----------------------------------------------------------------- + rv_json = '{"action": "' && iv_action && '", "type": "' && iv_type + && '", "name": "' && iv_name && '"}'. + ENDMETHOD. + +ENDCLASS. diff --git a/tests/e2e_project/functions/zgroup/z_my_func.abap b/tests/e2e_project/functions/zgroup/z_my_func.abap new file mode 100644 index 0000000..3bf191b --- /dev/null +++ b/tests/e2e_project/functions/zgroup/z_my_func.abap @@ -0,0 +1,5 @@ +FUNCTION z_my_func + EXPORTING + VALUE(ev_result) TYPE string. + ev_result = 'hello'. +ENDFUNCTION. diff --git a/tests/e2e_project/manifest.json b/tests/e2e_project/manifest.json new file mode 100644 index 0000000..a47d819 --- /dev/null +++ b/tests/e2e_project/manifest.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "last_init": "2026-05-25T01:52:21+00:00", + "last_refresh": null, + "objects": { + "ZHELLO": { + "type": "report", + "file": "reports/zhello.abap", + "system_status": "active", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:40+00:00", + "last_sync_result": "success" + }, + "ZSAPILOT_OBJ": { + "type": "report", + "file": "reports/zsapilot_obj.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:39+00:00", + "last_sync_result": "failed" + }, + "ZCL_SAPILOT_CONSTANTS": { + "type": "class", + "file": "classes/zcl_sapilot_constants.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T02:09:38+00:00", + "last_sync_result": "success" + }, + "ZCL_SAPILOT_HTTP_HANDLER": { + "type": "class", + "file": "classes/zcl_sapilot_http_handler.abap", + "system_status": "active", + "corr_nr": "DEVK901362", + "depends_on": [], + "last_sync": "2026-05-25T02:09:37+00:00", + "last_sync_result": "success" + }, + "ZCL_SAPILOT_OP_ADMIN": { + "type": "class", + "file": "classes/zcl_sapilot_op_admin.abap", + "system_status": "active", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:38+00:00", + "last_sync_result": "failed" + }, + "ZGROUP/Z_MY_FUNC": { + "type": "function", + "file": "functions/zgroup/z_my_func.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": null, + "last_sync_result": "failed" + }, + "Z_STATUS": { + "type": "domain", + "file": "domains/z_status.abap", + "system_status": "not_exists", + "corr_nr": null, + "depends_on": [], + "last_sync": "2026-05-25T02:09:37+00:00", + "last_sync_result": "failed" + } + } +} \ No newline at end of file diff --git a/tests/test_e2e.md b/tests/test_e2e.md new file mode 100644 index 0000000..d1cf8fc --- /dev/null +++ b/tests/test_e2e.md @@ -0,0 +1,332 @@ +""" +sap-cli 端到端测试 — 使用真实项目目录 + mock SAP + +验证完整批量同步流程: + init → dry-run → sync --all → refresh + +运行方式: + python test/script/test_e2e.py +""" +from __future__ import annotations + +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +from unittest.mock import MagicMock + +# Windows UTF-8 +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +E2E_PROJECT = os.path.join(PROJECT_ROOT, "test", "e2e_project") + + +class TestE2EInit(unittest.TestCase): + """端到端: init 命令。""" + + def setUp(self): + # 复制测试项目到临时目录,避免污染原始文件 + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = self._mock_client() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _mock_client(self): + client = MagicMock() + client.get_object_status.return_value = { + "exists": True, "status": "active", "corr_nr": "DEVK901362" + } + return client + + def test_init_generates_manifest(self): + """init 正确生成清单,包含所有扫描到的对象。""" + import argparse + from sapcli.commands import cmd_init + from sapcli.manifest import Manifest + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + manifest = Manifest.load(self.tmpdir) + + # 验证扫描到的对象 + self.assertEqual(len(manifest.objects), 6) + self.assertIn("ZHELLO", manifest.objects) + self.assertIn("ZSAPILOT_OBJ", manifest.objects) + self.assertIn("ZCL_SAPILOT_CONSTANTS", manifest.objects) + self.assertIn("ZCL_SAPILOT_HTTP_HANDLER", manifest.objects) + self.assertIn("ZCL_SAPILOT_OP_ADMIN", manifest.objects) + self.assertIn("ZGROUP/Z_MY_FUNC", manifest.objects) + + # 验证类型映射 + self.assertEqual(manifest.get("ZHELLO").type, "report") + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").type, "class") + self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").type, "function") + + # 验证相对路径 + self.assertEqual(manifest.get("ZHELLO").file, "reports/zhello.abap") + self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").file, "functions/zgroup/z_my_func.abap") + + def test_init_manifest_json_structure(self): + """init 生成的 manifest.json 结构正确。""" + import argparse + from sapcli.commands import cmd_init + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + with open(os.path.join(self.tmpdir, "manifest.json"), "r", encoding="utf-8") as f: + data = json.load(f) + + self.assertEqual(data["version"], 1) + self.assertIsNotNone(data["last_init"]) + self.assertIsNone(data["last_refresh"]) + self.assertEqual(len(data["objects"]), 6) + + # 验证单个对象结构 + zhello = data["objects"]["ZHELLO"] + self.assertEqual(zhello["type"], "report") + self.assertEqual(zhello["file"], "reports/zhello.abap") + self.assertEqual(zhello["system_status"], "active") + self.assertEqual(zhello["corr_nr"], "DEVK901362") + self.assertEqual(zhello["depends_on"], []) + self.assertIsNone(zhello["last_sync"]) + self.assertEqual(zhello["last_sync_result"], "pending") + + def test_init_mixed_statuses(self): + """init 正确处理混合 SAP 状态(存在/不存在/未激活)。""" + import argparse + from sapcli.commands import cmd_init + from sapcli.manifest import Manifest + + def mock_status(uri): + if "zhello" in uri: + return {"exists": True, "status": "active", "corr_nr": "DEVK901362"} + elif "zsapilot_obj" in uri: + return {"exists": True, "status": "inactive", "corr_nr": None} + else: + return {"exists": False, "status": "not_exists", "corr_nr": None} + + self.client.get_object_status.side_effect = mock_status + + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + manifest = Manifest.load(self.tmpdir) + + self.assertEqual(manifest.get("ZHELLO").system_status, "active") + self.assertEqual(manifest.get("ZHELLO").corr_nr, "DEVK901362") + self.assertEqual(manifest.get("ZSAPILOT_OBJ").system_status, "inactive") + self.assertIsNone(manifest.get("ZSAPILOT_OBJ").corr_nr) + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").system_status, "not_exists") + + +class TestE2ESyncAll(unittest.TestCase): + """端到端: sync --all 命令。""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = self._mock_client() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _mock_client(self): + client = MagicMock() + client.object_exists.return_value = True + client.lock.return_value = ("handle", "DEVK901362") + client.unlock.return_value = True + client.set_source.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + client.get_object_status.return_value = { + "exists": True, "status": "active", "corr_nr": "DEVK901362" + } + return client + + def _create_manifest(self): + """先执行 init 创建清单。""" + import argparse + from sapcli.commands import cmd_init + args = argparse.Namespace(path=self.tmpdir) + cmd_init(args, self.client) + + def test_dry_run_no_side_effects(self): + """dry-run 不修改清单状态。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=True, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + # 所有对象应保持 pending 状态 + for entry in manifest.objects.values(): + self.assertEqual(entry.last_sync_result, "pending") + + # activate 不应被调用 + self.client.activate.assert_not_called() + + def test_sync_all_updates_manifest(self): + """sync --all 成功后所有对象标记为 success。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + for name, entry in manifest.objects.items(): + self.assertEqual(entry.system_status, "active", f"{name} 应为 active") + self.assertEqual(entry.last_sync_result, "success", f"{name} 应为 success") + self.assertIsNotNone(entry.last_sync, f"{name} 应有 last_sync") + + def test_sync_all_respects_execution_order(self): + """sync --all 按类型优先级执行。""" + import argparse + from sapcli.commands import cmd_sync_all + + self._create_manifest() + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + # 验证 activate 的调用顺序 + calls = self.client.activate.call_args_list + names = [call[0][0] for call in calls] # 第一个位置参数是 name + + # interface 应在 class 前,class 应在 report 前 + class_idx = next((i for i, n in enumerate(names) if "ZCL_SAPILOT" in n), None) + report_idx = next((i for i, n in enumerate(names) if "ZHELLO" in n or "ZSAPILOT" in n), None) + + if class_idx is not None and report_idx is not None: + self.assertLess(class_idx, report_idx, "class 应在 report 之前执行") + + def test_sync_all_with_dependency_failure(self): + """sync --all 某对象失败时依赖它的对象被跳过。""" + import argparse + from sapcli.commands import cmd_sync_all + from sapcli.manifest import Manifest, ManifestEntry, init_manifest + + # 手动创建带依赖的清单 + m = init_manifest(self.tmpdir) + m.upsert(ManifestEntry( + name="ZCL_SAPILOT_CONSTANTS", type="class", + file="classes/zcl_sapilot_constants.abap", + system_status="inactive", last_sync_result="pending", + )) + m.upsert(ManifestEntry( + name="ZSAPILOT_OBJ", type="report", + file="reports/zsapilot_obj.abap", + system_status="inactive", last_sync_result="pending", + depends_on=["ZCL_SAPILOT_CONSTANTS"], + )) + m.save() + + # 让 class 的 activate 失败 + def mock_activate(name, uri, corr_nr=None): + if "zcl_sapilot_constants" in name.lower(): + return (False, [{"type": "E", "line": "1", "text": "test error", "href": ""}]) + return (True, []) + self.client.activate.side_effect = mock_activate + + args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False) + cmd_sync_all(args, self.client) + + manifest = Manifest.load(self.tmpdir) + self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").last_sync_result, "failed") + self.assertEqual(manifest.get("ZSAPILOT_OBJ").last_sync_result, "skipped") + + +class TestE2ERefresh(unittest.TestCase): + """端到端: refresh 命令。""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_") + shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True) + self.client = MagicMock() + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_refresh_preserves_sync_history(self): + """refresh 只更新 system_status 和 corr_nr,不动 last_sync。""" + import argparse + from sapcli.commands import cmd_refresh + from sapcli.manifest import Manifest, ManifestEntry, init_manifest + + # 创建带同步历史的清单 + m = init_manifest(self.tmpdir) + m.upsert(ManifestEntry( + name="ZCL_SAPILOT_CONSTANTS", type="class", + file="classes/zcl_sapilot_constants.abap", + system_status="active", corr_nr="DEVK901362", + last_sync="2026-01-01T00:00:00", last_sync_result="success", + )) + m.save() + + # SAP 端状态变更 + self.client.get_object_status.return_value = { + "exists": True, "status": "inactive", "corr_nr": "DEVK901363" + } + + args = argparse.Namespace(path=self.tmpdir) + cmd_refresh(args, self.client) + + manifest = Manifest.load(self.tmpdir) + entry = manifest.get("ZCL_SAPILOT_CONSTANTS") + + # 状态已更新 + self.assertEqual(entry.system_status, "inactive") + self.assertEqual(entry.corr_nr, "DEVK901363") + + # 同步历史不变 + self.assertEqual(entry.last_sync, "2026-01-01T00:00:00") + self.assertEqual(entry.last_sync_result, "success") + + +# ════════════════════════════════════════════════════════════════ +# 运行 +# ════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + print("=" * 60) + print(" sap-cli 端到端测试 — 真实项目目录 + mock SAP") + print("=" * 60) + print(f" 测试项目: {E2E_PROJECT}") + + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + for cls in [TestE2EInit, TestE2ESyncAll, TestE2ERefresh]: + suite.addTests(loader.loadTestsFromTestCase(cls)) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + print("\n" + "=" * 60) + if result.wasSuccessful(): + print(f" ✓ 全部通过! ({result.testsRun} 个测试)") + else: + print(f" ✗ 失败: {len(result.failures)} 错误: {len(result.errors)}") + print("=" * 60) + + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_sapcli.py b/tests/test_sapcli.py new file mode 100644 index 0000000..ef45779 --- /dev/null +++ b/tests/test_sapcli.py @@ -0,0 +1,708 @@ +""" +sap-cli 单元测试(v3 — 匹配重构后 API) + +运行: python tests/test_sapcli.py +""" + +import io +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +# 将项目根目录加入 sys.path,支持从任意位置运行测试 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")) + + +# ═══════════════════════════════════════════ +# TestTypes +# ═══════════════════════════════════════════ +class TestTypes(unittest.TestCase): + """对象类型注册与名称解析。""" + + def test_type_registry(self): + from sapcli.types import all_type_keys, get_type_config + keys = all_type_keys() + self.assertGreaterEqual(len(keys), 10) + for k in keys: + cfg = get_type_config(k) + self.assertIsNotNone(cfg) + self.assertTrue(cfg.obj_uri_template) + + def test_parse_report_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZMY_REPORT", "report") + self.assertEqual(parsed.display_name, "ZMY_REPORT") + + def test_parse_class_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZCL_MY_CLASS", "class") + self.assertEqual(parsed.display_name, "ZCL_MY_CLASS") + + def test_parse_function_name(self): + from sapcli.types import parse_object_name + parsed = parse_object_name("ZGROUP/Z_MY_FUNC", "function") + self.assertIn("z_my_func", parsed.obj_uri) + + def test_parse_function_name_no_slash(self): + from sapcli.types import parse_object_name + from sapcli.exceptions import InvalidNameError + with self.assertRaises(InvalidNameError): + parse_object_name("Z_MY_FUNC", "function") + + def test_new_types_registered(self): + from sapcli.types import all_type_keys, get_type_config + keys = all_type_keys() + # Check that all 10 core types are registered + expected = ["report", "class", "function", "functiongroup", "interface", + "domain", "dataelement", "table", "structure", "tabletype"] + for t in expected: + self.assertIn(t, keys) + cfg = get_type_config(t) + self.assertIsNotNone(cfg) + + def test_r3tr_object_code(self): + from sapcli.types import get_r3tr_object_code + # 有独立 R3TR 代码 + self.assertEqual(get_r3tr_object_code("class"), "CLAS") + self.assertEqual(get_r3tr_object_code("report"), "PROG") + self.assertEqual(get_r3tr_object_code("table"), "TABL") + self.assertEqual(get_r3tr_object_code("domain"), "DOMA") + self.assertEqual(get_r3tr_object_code("cdsview"), "DDLS") + # 非独立 R3TR 对象(属父对象) + self.assertIsNone(get_r3tr_object_code("function")) + self.assertIsNone(get_r3tr_object_code("include")) + # 未知类型 + self.assertIsNone(get_r3tr_object_code("not_a_type")) + + +# ═══════════════════════════════════════════ +# TestExceptions +# ═══════════════════════════════════════════ +class TestExceptions(unittest.TestCase): + + def test_exceptions(self): + from sapcli.exceptions import ( + SapCliError, ConfigError, LoginError, LockError, + ActivationError, CreateError, DeleteError, + SyntaxCheckError, InvalidNameError, + ObjectNotFoundError, CyclicDependencyError, + ) + for ExcCls in [SapCliError, ConfigError, LoginError, LockError, + ActivationError, CreateError, DeleteError, + SyntaxCheckError, InvalidNameError]: + e = ExcCls("test error") + self.assertIsInstance(e, SapCliError) + + def test_cyclic_dependency(self): + from sapcli.exceptions import CyclicDependencyError + e = CyclicDependencyError(["A", "B", "C"]) + self.assertIn("A", str(e)) + self.assertEqual(e.cycle_members, ["A", "B", "C"]) + + +# ═══════════════════════════════════════════ +# TestConfig +# ═══════════════════════════════════════════ +class TestConfig(unittest.TestCase): + + def setUp(self): + self._saved_env = {k: os.environ.pop(k, None) for k in + ["SAP_HOST", "SAP_CLIENT", "SAP_USER", "SAP_PASSWORD", "SAPCLI_PROFILE"]} + + def tearDown(self): + for k, v in self._saved_env.items(): + if v is not None: + os.environ[k] = v + + def test_config_from_env(self): + from sapcli.config import load_config + with patch.dict(os.environ, { + "SAP_HOST": "http://test:8000", + "SAP_CLIENT": "200", + "SAP_USER": "testuser", + "SAP_PASSWORD": "testpass", + }): + cfg, _ = load_config("/nonexistent/config.ini") + self.assertEqual(cfg.host, "http://test:8000") + self.assertEqual(cfg.client, "200") + + def test_config_no_file_returns_empty(self): + """无配置文件且无环境变量时,load_config 抛出 ConfigError。""" + from sapcli.config import load_config + from sapcli.exceptions import ConfigError + # clear env vars and prevent reading any config file + with patch.dict(os.environ, {}, clear=True), \ + patch("sapcli.config.os.path.isfile", return_value=False): + with self.assertRaises(ConfigError): + load_config("/nonexistent/config.ini") + + def test_multi_profile(self): + from sapcli.config import load_config + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("[SAP]\nhost=http://dev:8000\nclient=100\nuser=devuser\npassword=devpass\n") + f.write("[QAS]\nhost=http://qas:8000\nclient=200\nuser=qasuser\npassword=qaspass\n") + f.write("[PRD]\nhost=http://prd:8000\nclient=300\nuser=prduser\npassword=prdpass\n") + tmppath = f.name + try: + # 默认 profile=SAP + cfg, _ = load_config(tmppath) + self.assertEqual(cfg.host, "http://dev:8000") + # 指定 QAS + cfg, _ = load_config(tmppath, profile="QAS") + self.assertEqual(cfg.host, "http://qas:8000") + self.assertEqual(cfg.client, "200") + # 指定 PRD + cfg, _ = load_config(tmppath, profile="PRD") + self.assertEqual(cfg.host, "http://prd:8000") + finally: + os.unlink(tmppath) + + def test_profile_fallback(self): + from sapcli.config import load_config + from sapcli.exceptions import ConfigError + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("[SAP]\nhost=http://dev:8000\nclient=100\nuser=devuser\npassword=devpass\n") + tmppath = f.name + try: + # 不存在的 profile → section 为空 → ConfigError + with self.assertRaises(ConfigError): + load_config(tmppath, profile="NONEXISTENT") + finally: + os.unlink(tmppath) + + +# ═══════════════════════════════════════════ +# TestManifest +# ═══════════════════════════════════════════ +class TestManifest(unittest.TestCase): + + def _make_manifest(self, td): + from sapcli.manifest import Manifest + m = Manifest(_project_path=td) + return m + + def _make_entry(self, name, obj_type, file, **kw): + from sapcli.manifest import ManifestEntry + return ManifestEntry(name=name, type=obj_type, file=file, **kw) + + def test_manifest_add_and_get(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_TEST", "report", "reports/z_test.abap")) + entry = m.get("Z_TEST") + self.assertIsNotNone(entry) + self.assertEqual(entry.type, "report") + + def test_manifest_remove(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_A", "domain", "domains/z_a.abap")) + m.remove("Z_A") + self.assertIsNone(m.get("Z_A")) + + def test_manifest_pending(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_A", "domain", "domains/z_a.abap", system_status="active", last_sync_result="success")) + m.upsert(self._make_entry("Z_B", "report", "reports/z_b.abap", system_status="inactive")) + m.upsert(self._make_entry("Z_C", "class", "classes/z_c.abap", system_status="not_exists")) + pending = m.pending_objects() + names = [p.name for p in pending] + self.assertIn("Z_B", names) + self.assertIn("Z_C", names) + self.assertNotIn("Z_A", names) + + def test_manifest_persistence(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + m.upsert(self._make_entry("Z_TEST", "report", "reports/z_test.abap")) + m.save() + self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json"))) + + def test_manifest_load_empty(self): + with tempfile.TemporaryDirectory() as td: + m = self._make_manifest(td) + self.assertEqual(len(m.objects), 0) + + +# ═══════════════════════════════════════════ +# TestScanner +# ═══════════════════════════════════════════ +class TestScanner(unittest.TestCase): + + def test_scanner_basic(self): + from sapcli.scanner import scan_project + with tempfile.TemporaryDirectory() as td: + os.makedirs(os.path.join(td, "reports")) + with open(os.path.join(td, "reports", "zmy_report.abap"), "w") as f: + f.write("WRITE: / 'Hello'.") + result = scan_project(td) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "ZMY_REPORT") + self.assertEqual(result[0].type, "report") + + def test_scanner_empty_dir(self): + from sapcli.scanner import scan_project + with tempfile.TemporaryDirectory() as td: + result = scan_project(td) + self.assertEqual(len(result), 0) + + +# ═══════════════════════════════════════════ +# TestSorter +# ═══════════════════════════════════════════ +class TestSorter(unittest.TestCase): + + def _make_entry(self, name, obj_type, deps=None): + from sapcli.manifest import ManifestEntry + return ManifestEntry(name=name, type=obj_type, file=f"{name}.abap", + depends_on=deps or []) + + def test_sorter_basic(self): + from sapcli.sorter import topological_sort + objects = [ + self._make_entry("Z_B", "class", ["Z_A"]), + self._make_entry("Z_A", "domain", []), + ] + result = topological_sort(objects) + names = [o.name for o in result] + self.assertLess(names.index("Z_A"), names.index("Z_B")) + + def test_sorter_no_deps(self): + from sapcli.sorter import topological_sort + objects = [ + self._make_entry("Z_A", "domain"), + self._make_entry("Z_B", "report"), + ] + result = topological_sort(objects) + self.assertEqual(len(result), 2) + + def test_sorter_cycle_detection(self): + from sapcli.sorter import topological_sort + from sapcli.exceptions import CyclicDependencyError + objects = [ + self._make_entry("Z_A", "class", ["Z_B"]), + self._make_entry("Z_B", "class", ["Z_A"]), + ] + with self.assertRaises(CyclicDependencyError): + topological_sort(objects) + + def test_sorter_external_dep_ignored(self): + from sapcli.sorter import topological_sort + objects = [self._make_entry("Z_A", "domain", ["Z_EXTERNAL"])] + result = topological_sort(objects) + self.assertEqual(len(result), 1) + + def test_sorter_empty(self): + from sapcli.sorter import topological_sort + self.assertEqual(topological_sort([]), []) + + +# ═══════════════════════════════════════════ +# TestDdic +# ═══════════════════════════════════════════ +class TestDdic(unittest.TestCase): + + def test_ddic_domain_xml(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml("Z_TEST", "Test domain") + self.assertIn("Z_TEST", xml) + self.assertIn("Test domain", xml) + self.assertIn("CHAR", xml) + + def test_ddic_domain_fix_values(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition( + datatype="CHAR", length=1, + fix_values=[{"low": "A", "text": "Active"}, {"low": "I", "text": "Inactive"}], + ) + xml = d.to_xml("Z_STATUS", "Status") + self.assertIn("Active", xml) + + def test_ddic_dataelement_with_domain(self): + from sapcli.ddic import DataElementDefinition + de = DataElementDefinition(domain_name="Z_STATUS") + xml = de.to_xml("Z_STATUS_DE", "Status element", "$TMP") + self.assertIn("Z_STATUS", xml) + + def test_ddic_dataelement_builtin(self): + from sapcli.ddic import DataElementDefinition + de = DataElementDefinition(datatype="CHAR", length=20) + xml = de.to_xml("Z_NAME_DE", "Name element", "$TMP") + self.assertIn("CHAR", xml) + + def test_ddic_table_ddl(self): + from sapcli.ddic import TableDefinition, TableField + t = TableDefinition( + fields=[ + TableField(name="MANDT", type_name="MANDT"), + TableField(name="ID", type_name="Z_ID"), + ], + ) + ddl = t.to_ddl("ZMY_TABLE", "My table") + self.assertIn("zmy_table", ddl) + self.assertIn("define table", ddl) + self.assertIn("MANDT", ddl) + + def test_ddic_structure_ddl(self): + from sapcli.ddic import StructureDefinition, TableField + s = StructureDefinition( + fields=[ + TableField(name="ID", type_name="Z_ID"), + TableField(name="NAME", type_name="Z_NAME"), + ], + ) + ddl = s.to_ddl("ZMY_STRUCT", "My struct") + self.assertIn("define structure", ddl) + + def test_ddic_tabletype_xml(self): + from sapcli.ddic import TableTypeDefinition + tt = TableTypeDefinition(line_type="ZMY_STRUCT") + xml = tt.to_xml("ZTY_TABLE", "Table type", "$TMP") + self.assertIn("ZMY_STRUCT", xml) + self.assertIn("TTYP", xml) + + def test_ddic_json_builder(self): + from sapcli.ddic import build_definition + result = build_definition({"name": "Z_TEST", "datatype": "NUMC", "length": 6}, "domain") + self.assertIn("NUMC", result) + + +# ═══════════════════════════════════════════ +# TestXmlUtils +# ═══════════════════════════════════════════ +class TestXmlUtils(unittest.TestCase): + + def test_xml_escape_basic(self): + from sapcli.utils.xml_utils import xml_escape + self.assertEqual(xml_escape("hello"), "hello") + + def test_xml_escape_special_chars(self): + from sapcli.utils.xml_utils import xml_escape + result = xml_escape('') + self.assertIn("<script>", result) + self.assertIn(""xss"", result) + + def test_xml_escape_quotes(self): + from sapcli.utils.xml_utils import xml_escape + result = xml_escape('say "hello" & \'bye\'') + self.assertIn(""", result) + self.assertIn("&", result) + + def test_ddl_escape(self): + from sapcli.utils.xml_utils import ddl_escape + self.assertEqual(ddl_escape("it's O'Brien's"), "it''s O''Brien''s") + + +# ═══════════════════════════════════════════ +# TestAuth +# ═══════════════════════════════════════════ +class TestAuth(unittest.TestCase): + + def test_resolve_password_env_priority(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", True): + result = resolve_password("h", "c", "u", config_password="cfg", env_password="env") + self.assertEqual(result, "env") + + def test_resolve_password_keyring(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", True), \ + patch("sapcli.password.get_password", return_value="kr_pass"): + result = resolve_password("h", "c", "u", config_password="cfg") + self.assertEqual(result, "kr_pass") + + def test_resolve_password_config_fallback(self): + from sapcli.auth import resolve_password + with patch("sapcli.password._KEYRING_AVAILABLE", False): + result = resolve_password("h", "c", "u", config_password="cfg_pass") + self.assertEqual(result, "cfg_pass") + + def test_keyring_unavailable(self): + from sapcli.auth import get_password, set_password + with patch("sapcli.password._KEYRING_AVAILABLE", False): + self.assertIsNone(get_password("h", "c", "u")) + self.assertFalse(set_password("h", "c", "u", "p")) + + +# ═══════════════════════════════════════════ +# TestXmlInjection +# ═══════════════════════════════════════════ +class TestXmlInjection(unittest.TestCase): + + def test_xml_injection_in_ddic(self): + from sapcli.ddic import DomainDefinition + evil = 'ZTEST<>&"' + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml(evil, evil) + self.assertNotIn('name="ZTEST<', xml) + self.assertIn("<", xml) + self.assertIn(">", xml) + + def test_xml_injection_in_ddl(self): + from sapcli.ddic import TableDefinition, TableField + evil_desc = "O'Brien's \"test\"" + t = TableDefinition(fields=[TableField(name="ID", type_name="Z_ID")]) + ddl = t.to_ddl("ZMY_TABLE", evil_desc) + self.assertIn("O''Brien", ddl) + + def test_empty_name_handling(self): + from sapcli.ddic import DomainDefinition + d = DomainDefinition(datatype="CHAR", length=10) + xml = d.to_xml("", "") + self.assertIsInstance(xml, str) + + +# ═══════════════════════════════════════════ +# TestClientMethods +# ═══════════════════════════════════════════ +class TestClientMethods(unittest.TestCase): + + def _make_client(self): + from sapcli.client import ADTClient + client = ADTClient.__new__(ADTClient) + client.host = "http://test:8000" + client.csrf_token = "test-token" + client.sap_client = "100" + client.session = MagicMock() + client._stateful = False + return client + + def test_list_objects(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.content = ( + b'' + b'' + b'' + b'' + b'' + b'' + ) + client.session.get.return_value = mock_resp + result = client.list_objects(obj_type="report") + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "Z_TEST") + + def test_transport_info(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.content = ( + b'' + b'' + ) + client.session.get.return_value = mock_resp + result = client.transport_info("DEVK901362") + self.assertIsInstance(result, dict) + + def test_pretty_print(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = 'FORMATTED CODE' + client.session.post.return_value = mock_resp + result = client.pretty_print("raw code") + self.assertIsInstance(result, str) + + +# ═══════════════════════════════════════════ +# TestAnalyze +# ═══════════════════════════════════════════ +class TestAnalyze(unittest.TestCase): + + def test_analyze_type_ref(self): + from sapcli.commands.analyze import _analyze_dependencies + code = 'DATA: lo_obj TYPE REF TO zcl_my_class.\nDATA: lo_if TYPE REF TO zif_my_interface.\n' + deps = _analyze_dependencies(code) + self.assertIn("ZCL_MY_CLASS", deps) + + def test_analyze_call_function(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "CALL FUNCTION 'Z_MY_FUNC'\n EXPORTING iv_param = lv_val.\n" + deps = _analyze_dependencies(code) + self.assertIn("Z_MY_FUNC", deps) + + def test_analyze_select_table(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "SELECT * FROM zmy_table INTO TABLE @lt_data.\n" + deps = _analyze_dependencies(code) + self.assertIn("ZMY_TABLE", deps) + + def test_analyze_builtin_filtered(self): + from sapcli.commands.analyze import _analyze_dependencies + code = "DATA: lv_str TYPE string.\nDATA: lv_int TYPE i.\n" + deps = _analyze_dependencies(code) + self.assertNotIn("STRING", deps) + self.assertNotIn("I", deps) + + +# ═══════════════════════════════════════════ +# TestScaffold +# ═══════════════════════════════════════════ +class TestScaffold(unittest.TestCase): + + def test_scaffold_alv_report(self): + from sapcli.commands.scaffold import _alv_report_template + result = _alv_report_template("ZMY_REPORT", "$TMP") + self.assertIn("ZMY_REPORT", result) + self.assertIn("cl_salv_table", result) + + def test_scaffold_interface_class(self): + from sapcli.commands.scaffold import _interface_class_template + result = _interface_class_template("ZCL_MY_CLASS", "$TMP") + self.assertIn("ZCL_MY_CLASS", result) + self.assertIn("INTERFACE", result) + + def test_scaffold_data_model(self): + from sapcli.commands.scaffold import _data_model_template + result = _data_model_template("ZMY_MODEL", "$TMP") + self.assertIn("ZMY_MODEL", result) + self.assertIn("define table", result) + + def test_scaffold_bapi_wrapper(self): + from sapcli.commands.scaffold import _bapi_wrapper_template + result = _bapi_wrapper_template("Z_MY_BAPI", "$TMP") + self.assertIn("Z_MY_BAPI", result) + self.assertIn("CLASS", result) + + +# ═══════════════════════════════════════════ +# TestOutput +# ═══════════════════════════════════════════ +class TestOutput(unittest.TestCase): + + def test_print_header(self): + from sapcli.cli.output import print_header + captured = io.StringIO() + with patch("sys.stdout", captured): + print_header("测试标题") + self.assertIn("测试标题", captured.getvalue()) + + def test_print_success(self): + from sapcli.cli.output import print_success + captured = io.StringIO() + with patch("sys.stdout", captured): + print_success("操作成功") + self.assertIn("✓", captured.getvalue()) + + def test_print_error(self): + from sapcli.cli.output import print_error + captured = io.StringIO() + with patch("sys.stdout", captured): + print_error("操作失败") + self.assertIn("✗", captured.getvalue()) + + +# ═══════════════════════════════════════════ +# TestDiff +# ═══════════════════════════════════════════ +class TestDiff(unittest.TestCase): + + def test_unified_diff_logic(self): + """验证 difflib.unified_diff 逻辑(与 diff_cmd.py 一致)。""" + import difflib + old = "LINE 1\nLINE 2\nLINE 3\n".splitlines(keepends=True) + new = "LINE 1\nLINE 2 MODIFIED\nLINE 3\n".splitlines(keepends=True) + diff = list(difflib.unified_diff(old, new, fromfile="SAP", tofile="LOCAL", lineterm="")) + self.assertTrue(any("LINE 2" in l for l in diff)) + self.assertTrue(any(l.startswith("-") and "LINE 2" in l and not l.startswith("---") for l in diff)) + self.assertTrue(any(l.startswith("+") and "MODIFIED" in l and not l.startswith("+++") for l in diff)) + + +# ═══════════════════════════════════════════ +# TestOpenSpec +# ═══════════════════════════════════════════ +class TestOpenSpec(unittest.TestCase): + + def test_openspec_structure_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isdir(os.path.join(project_root, "openspec"))) + + def test_openspec_config_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "openspec", "config.yaml"))) + + def test_openspec_specs_exist(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + specs_dir = os.path.join(project_root, "openspec", "specs") + self.assertTrue(os.path.isdir(specs_dir)) + # Source of Truth — 7 domains describing current system behavior + for domain in ["connection", "object-lifecycle", "batch-ops", + "search-browse", "transport", "quality", "config"]: + self.assertTrue(os.path.isfile(os.path.join(specs_dir, domain, "spec.md"))) + + +# ═══════════════════════════════════════════ +# TestCLAUDEmd +# ═══════════════════════════════════════════ +class TestCLAUDEmd(unittest.TestCase): + + def test_claude_md_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "docs", "dev", "CLAUDE.md"))) + + def test_agents_md_exists(self): + base = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(base) + self.assertTrue(os.path.isfile(os.path.join(project_root, "docs", "dev", "AGENTS.md"))) + + +# ═══════════════════════════════════════════ +# TestModuleStructure +# ═══════════════════════════════════════════ +class TestModuleStructure(unittest.TestCase): + + def test_cli_package(self): + import sapcli.cli + import sapcli.cli.parser + import sapcli.cli.output + + def test_commands_package(self): + import sapcli.commands + import sapcli.commands.crud + import sapcli.commands.batch + import sapcli.commands.config_cmd + import sapcli.commands.search + import sapcli.commands.transport + import sapcli.commands.quality + import sapcli.commands.diff_cmd + import sapcli.commands.package_cmd + import sapcli.commands.cds + import sapcli.commands.analyze + import sapcli.commands.scaffold + + def test_utils_package(self): + import sapcli.utils + import sapcli.utils.xml_utils + + def test_auth_module(self): + import sapcli.auth + + def test_all_commands_importable(self): + from sapcli.commands import ( + cmd_create, cmd_delete, cmd_download, cmd_info, cmd_sync, + cmd_init, cmd_refresh, cmd_sync_all, cmd_config, + cmd_list, cmd_whereused, cmd_search, + cmd_transport, cmd_check, cmd_format, + cmd_diff, cmd_package, cmd_cds, + cmd_analyze, cmd_scaffold, + ) + + +# ═══════════════════════════════════════════ +if __name__ == "__main__": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + unittest.main(verbosity=2) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_activate.py b/tests/unit/test_activate.py new file mode 100644 index 0000000..f51d9dd --- /dev/null +++ b/tests/unit/test_activate.py @@ -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() diff --git a/tests/unit/test_app_config.py b/tests/unit/test_app_config.py new file mode 100644 index 0000000..3445c86 --- /dev/null +++ b/tests/unit/test_app_config.py @@ -0,0 +1,327 @@ +"""app.py main() 和 config_cmd.py 单元测试。 + +运行: python tests/unit/test_app_config.py +""" +from __future__ import annotations + +import io +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, call + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets")) + +from sapcli.cli.app import main +from sapcli.commands.config_cmd import cmd_config +from sapcli.exceptions import ConfigError, SapCliError + + +# ────────────────────────────────────────────────────────────────────── +# 辅助:构造一个假的 SAPConfig +# ────────────────────────────────────────────────────────────────────── +def _fake_config(): + """返回一个 (SAPConfig, loaded_from) 元组。""" + from sapcli.config import SAPConfig + cfg = SAPConfig(host="sap.example.com", client="100", user="TESTUSER", password="secret123") + return cfg, "/tmp/fake_config.ini" + + +# ────────────────────────────────────────────────────────────────────── +# app.py main() 测试 +# ────────────────────────────────────────────────────────────────────── +class TestMainNoCommand(unittest.TestCase): + """无子命令时 print_help + exit(1)。""" + + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_no_command(self, mock_enc, mock_log, mock_ssl): + with patch("sys.argv", ["sap-cli"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainConfigShow(unittest.TestCase): + """config 命令 → cmd_config 被调用。""" + + @patch("sapcli.cli.app.cmd_config") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_config_show(self, mock_enc, mock_log, mock_ssl, mock_cmd_config): + with patch("sys.argv", ["sap-cli", "config", "show"]): + main() + mock_cmd_config.assert_called_once() + # 第一个位置参数是 args (Namespace) + args_passed = mock_cmd_config.call_args[0][0] + self.assertEqual(args_passed.command, "config") + + +class TestMainAuthLogin(unittest.TestCase): + """auth login → cmd_auth_login 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_login") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_login(self, mock_enc, mock_log, mock_ssl, mock_login): + with patch("sys.argv", ["sap-cli", "auth", "login"]): + main() + mock_login.assert_called_once() + + +class TestMainAuthLogout(unittest.TestCase): + """auth logout → cmd_auth_logout 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_logout") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_logout(self, mock_enc, mock_log, mock_ssl, mock_logout): + with patch("sys.argv", ["sap-cli", "auth", "logout"]): + main() + mock_logout.assert_called_once() + + +class TestMainAuthStatus(unittest.TestCase): + """auth status (无子命令) → cmd_auth_status 被调用。""" + + @patch("sapcli.cli.app.cmd_auth_status") + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_auth_status(self, mock_enc, mock_log, mock_ssl, mock_status): + with patch("sys.argv", ["sap-cli", "auth", "status"]): + main() + mock_status.assert_called_once() + + +class TestMainConfigError(unittest.TestCase): + """load_config 抛 ConfigError → exit(1)。""" + + @patch("sapcli.cli.app.load_config", side_effect=ConfigError("配置缺失")) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_config_error(self, mock_enc, mock_log, mock_ssl, mock_load): + with patch("sys.argv", ["sap-cli", "download", "--name", "ZT", "--type", "report", "--path", "./out"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainDownloadSuccess(unittest.TestCase): + """download 命令全链路 mock:load_config → ADTClient → login → cmd_download。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_download_success(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_client = MagicMock() + mock_adt_cls.return_value = mock_client + + with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]): + main() + + # 验证 ADTClient 被正确构造 + mock_adt_cls.assert_called_once_with( + "sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=False + ) + # 验证 login 被调用 + mock_client.login.assert_called_once() + # 验证 cmd_download 被调用 + mock_download.assert_called_once() + # cmd_download 的第二个参数应该是 mock_client + self.assertIs(mock_download.call_args[0][1], mock_client) + + +class TestMainSapCliError(unittest.TestCase): + """handler 抛 SapCliError → exit(1)。""" + + @patch("sapcli.cli.app.cmd_download", side_effect=SapCliError("下载失败")) + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_sap_cli_error(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainVerifySslFlag(unittest.TestCase): + """--verify-ssl 参数传递给 ADTClient。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_verify_ssl_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", [ + "sap-cli", "--verify-ssl", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]): + main() + + # verify_ssl 应为 True + mock_adt_cls.assert_called_once_with( + "sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=True + ) + + +class TestMainProfileFlag(unittest.TestCase): + """--profile 参数传递给 load_config。""" + + @patch("sapcli.cli.app.cmd_download") + @patch("sapcli.cli.app.ADTClient") + @patch("sapcli.cli.app.load_config", return_value=_fake_config()) + @patch("sapcli.cli.app._suppress_ssl_warnings") + @patch("sapcli.cli.app._setup_logging") + @patch("sapcli.cli.app._setup_encoding") + def test_main_profile_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download): + mock_adt_cls.return_value = MagicMock() + + with patch("sys.argv", [ + "sap-cli", "--profile", "DEV", + "download", "--name", "ZTEST", "--type", "report", "--path", "./out", + ]): + main() + + # load_config 应收到 profile="DEV" + mock_load.assert_called_once() + # load_config(config_path, profile=...) + # parser 中 --config 的 dest 是 "config",--profile 的 dest 是 "profile" + # load_config 调用签名: load_config(args.config, profile=getattr(args, "profile", None)) + call_kwargs = mock_load.call_args + self.assertEqual(call_kwargs[0][0], None) # args.config = None + # 第二个参数是 profile + # load_config 被调用为 load_config(args.config, profile=args.profile) + # 所以要看 kwargs 或位置参数 + if call_kwargs[1]: + self.assertEqual(call_kwargs[1].get("profile"), "DEV") + else: + # 可能是位置参数方式传递 + self.assertEqual(call_kwargs[0][1], "DEV") + + +# ────────────────────────────────────────────────────────────────────── +# config_cmd.py 测试 +# ────────────────────────────────────────────────────────────────────── +class TestConfigShowDefault(unittest.TestCase): + """config show → 显示默认配置。""" + + # _config_show 内部用 from sapcli.config import load_config,所以 patch 源模块 + @patch("sapcli.config.load_config", return_value=_fake_config()) + def test_config_show_default(self, mock_load): + import argparse + args = argparse.Namespace(config=None, config_action="show") + # 捕获 stdout + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("sap-cli 当前配置", output) + self.assertIn("sap.example.com", output) + self.assertIn("100", output) + self.assertIn("TESTUSER", output) + self.assertIn("***", output) + + +class TestConfigListProfiles(unittest.TestCase): + """config list-profiles → 显示多 profile。""" + + def test_config_list_profiles(self): + import argparse + import configparser + + # 创建一个临时配置文件包含多个 section + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + parser = configparser.ConfigParser() + parser.add_section("SAP") + parser.set("SAP", "host", "sap.example.com") + parser.add_section("DEV") + parser.set("DEV", "host", "dev.sap.example.com") + parser.write(f) + tmp_path = f.name + + try: + args = argparse.Namespace(config=tmp_path, config_action="list-profiles") + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("sap-cli 可用 profile", output) + self.assertIn("SAP", output) + self.assertIn("DEV", output) + self.assertIn("(默认)", output) # SAP section 是默认的 + finally: + os.unlink(tmp_path) + + +class TestConfigSetWritesFile(unittest.TestCase): + """config set → 写入配置文件。""" + + def test_config_set_writes_file(self): + import argparse + import configparser + + with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f: + f.write("") + tmp_path = f.name + + try: + args = argparse.Namespace( + config=tmp_path, + config_action="set", + key="host", + value="newhost.example.com", + profile=None, + ) + with patch("sys.stdout", new_callable=io.StringIO): + cmd_config(args) + + # 验证文件已写入 + parser = configparser.ConfigParser() + parser.read(tmp_path, encoding="utf-8") + self.assertTrue(parser.has_section("SAP")) + self.assertEqual(parser.get("SAP", "host"), "newhost.example.com") + finally: + os.unlink(tmp_path) + + +class TestConfigSetInvalidKey(unittest.TestCase): + """config set 无效 key → 报错。""" + + def test_config_set_invalid_key(self): + import argparse + + args = argparse.Namespace( + config=None, + config_action="set", + key="invalid_key", + value="some_value", + profile=None, + ) + with patch("sys.stdout", new_callable=io.StringIO) as mock_out: + cmd_config(args) + + output = mock_out.getvalue() + self.assertIn("不支持的配置项", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_batch_analyze.py b/tests/unit/test_batch_analyze.py new file mode 100644 index 0000000..8748730 --- /dev/null +++ b/tests/unit/test_batch_analyze.py @@ -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) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..92ab61e --- /dev/null +++ b/tests/unit/test_cli.py @@ -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() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..5a8e2cd --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,1176 @@ +"""client.py 单元测试 — 30 个 ADT API 方法 + +运行: python tests/unit/test_client.py +""" +from __future__ import annotations + +import os +import sys +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.exceptions import ( + LoginError, SapCliError, CreateError, DeleteError, + ActivationError, SyntaxCheckError, LockError, +) + +# ADT XML 命名空间 +TM_NS = "http://www.sap.com/cts/adt/tm" +ADTCORE_NS = "http://www.sap.com/adt/core" +CHK_NS = "http://www.sap.com/adt/checklist" +LOCK_NS = "http://www.sap.com/adt/lock" + + +def _make_client() -> ADTClient: + """创建 mock 好的 ADTClient。""" + client = ADTClient.__new__(ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.user = "TESTUSER" + client.password = "TESTPASS" + client.csrf_token = "test-csrf-token" + client.session = MagicMock() + client._stateful = False + return client + + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + """快速创建 mock response。""" + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.content = content + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + return resp + + +# ═══════════════════════════════════════════ +# 基础方法 +# ═══════════════════════════════════════════ + +class TestHeaders(unittest.TestCase): + + def test_default(self): + h = _make_client()._headers() + self.assertEqual(h["content-type"], "application/xml") + self.assertEqual(h["x-csrf-token"], "test-csrf-token") + + def test_custom(self): + h = _make_client()._headers("text/plain") + self.assertEqual(h["content-type"], "text/plain") + + +class TestContextManager(unittest.TestCase): + + def test_enter_calls_login(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "TOK"}) + result = client.__enter__() + self.assertIs(result, client) + + def test_exit_closes_session(self): + client = _make_client() + client.__exit__(None, None, None) + client.session.close.assert_called_once() + + +# ═══════════════════════════════════════════ +# 认证 +# ═══════════════════════════════════════════ + +class TestLogin(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, headers={"x-csrf-token": "NEW_TOK"}) + self.assertTrue(client.login()) + self.assertEqual(client.csrf_token, "NEW_TOK") + + def test_failure(self): + client = _make_client() + client.session.get.return_value = _mock_resp(401) + with self.assertRaises(LoginError): + client.login() + + +# ═══════════════════════════════════════════ +# 对象存在检查 +# ═══════════════════════════════════════════ + +class TestObjectExists(unittest.TestCase): + + def test_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200) + self.assertTrue(client.object_exists("/uri")) + + def test_not_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.object_exists("/uri")) + + +# ═══════════════════════════════════════════ +# 源码读取 +# ═══════════════════════════════════════════ + +class TestGetSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, text="REPORT ztest.") + self.assertEqual(client.get_source("/src"), "REPORT ztest.") + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_source("/nonexistent") + + +# ═══════════════════════════════════════════ +# 锁管理 +# ═══════════════════════════════════════════ + +class TestLock(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'lh_123' + f'DEVK001' + ).encode()) + handle, corr = client.lock("/uri") + self.assertEqual(handle, "lh_123") + self.assertEqual(corr, "DEVK001") + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(403) + with self.assertRaises(LockError): + client.lock("/uri") + + def test_no_handle(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + ).encode()) + with self.assertRaises(LockError): + client.lock("/uri") + + +class TestUnlock(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200) + self.assertTrue(client.unlock("/uri", "lh")) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + self.assertFalse(client.unlock("/uri", "lh")) + + +# ═══════════════════════════════════════════ +# 源码写入 +# ═══════════════════════════════════════════ + +class TestSetSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client.set_source("/src", "code", "lh")) + + def test_with_corr_nr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001")) + + +# ═══════════════════════════════════════════ +# 传输请求 +# ═══════════════════════════════════════════ + +_TRANSPORT_XML = ( + f'' + f'' + f'' + f'' +).encode() + +_TRANSPORT_EMPTY = ( + f'' + f'' +).encode() + +# create_transport_request 返回的 XML(带 tm:number 属性) +_CREATE_TRANSPORT_RESP = ( + f'' + f'' + f'' + f'' +).encode() + + +class TestGetTransportRequest(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML) + self.assertEqual(client.get_transport_request(), "DEVK001") + + def test_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY) + self.assertIsNone(client.get_transport_request()) + + +class TestListTransportRequests(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_XML) + self.assertIsInstance(client.list_transport_requests(), list) + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TRANSPORT_EMPTY) + self.assertEqual(client.list_transport_requests(), []) + + +class TestCreateTransportRequest(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, content=_CREATE_TRANSPORT_RESP) + result = client.create_transport_request("My request") + self.assertEqual(result, "DEVK002") + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_transport_request("Fail") + + +# ═══════════════════════════════════════════ +# 激活 — 解析 type="E" 属性 +# ═══════════════════════════════════════════ + +class TestActivate(unittest.TestCase): + + def test_success_empty(self): + """空响应视为成功。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + + def test_failure_with_error_type(self): + """包含 type="E" 属性的元素视为失败。""" + client = _make_client() + xml_body = ( + f'' + f'' + f'' + f'Activation error' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + + def test_http_error(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + with self.assertRaises(ActivationError): + client.activate("ztest", "/uri") + + # ── NW 7.40 double-activate workaround(空响应验证路径)────────── + + def _verify_xml(self, version): + """构造 verify GET 响应的 XML(bytes),adtcore:version 指定。""" + return ( + f'' + f'' + f'' + ).encode() + + def test_empty_response_already_active(self): + """空响应 + verify 显示 active → 成功,无需重试(post 仅 1 次)。""" + client = _make_client() + # 激活 POST → 200 空响应 + client.session.post.return_value = _mock_resp(200, text="", content=b"") + # verify GET → 200, version="active" + client.session.get.return_value = _mock_resp(200, content=self._verify_xml("active")) + + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + # 区分真/假实现:已 active 时不应重试激活 + self.assertEqual(client.session.post.call_count, 1) + + def test_double_activate_success(self): + """空响应 + inactive → 重试一次 → active(double-activate 成功路径)。""" + client = _make_client() + # 激活 POST + 重试 POST → 均 200 空 + client.session.post.side_effect = [ + _mock_resp(200, text="", content=b""), + _mock_resp(200, text="", content=b""), + ] + # verify GET → inactive;再 verify GET → active + client.session.get.side_effect = [ + _mock_resp(200, content=self._verify_xml("inactive")), + _mock_resp(200, content=self._verify_xml("active")), + ] + + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + # 区分真/假实现:确实重试了,post 被调用 2 次 + self.assertEqual(client.session.post.call_count, 2) + # 两次 verify GET + self.assertEqual(client.session.get.call_count, 2) + + def test_double_activate_still_inactive(self): + """重试后仍 inactive → 失败并返回 SE09 提示(post 调用 2 次)。""" + client = _make_client() + client.session.post.side_effect = [ + _mock_resp(200, text="", content=b""), + _mock_resp(200, text="", content=b""), + ] + # 两次 verify GET 都返回 inactive + client.session.get.side_effect = [ + _mock_resp(200, content=self._verify_xml("inactive")), + _mock_resp(200, content=self._verify_xml("inactive")), + ] + + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + # 重试过一次,共 2 次 POST + self.assertEqual(client.session.post.call_count, 2) + # 返回的消息含 SE09 或 inactive 提示 + msg_text = " ".join(m.get("text", "") for m in msgs) + self.assertTrue("SE09" in msg_text or "inactive" in msg_text) + + +# ═══════════════════════════════════════════ +# 语法检查 — 同样解析 type="E" +# ═══════════════════════════════════════════ + +class TestSyntaxCheck(unittest.TestCase): + + def test_clean(self): + """空响应视为通过。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertTrue(ok) + + def test_with_errors(self): + client = _make_client() + xml_body = ( + f'' + f'' + f'' + f'Syntax error at line 1' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertFalse(ok) + + def test_http_error(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500) + with self.assertRaises(SyntaxCheckError): + client.syntax_check("ztest", "/uri") + + +# ═══════════════════════════════════════════ +# 删除对象(内部调 lock) +# ═══════════════════════════════════════════ + +class TestDeleteObject(unittest.TestCase): + + def _mock_lock_ok(self, client): + client.session.post.return_value = _mock_resp(200, content=( + f'lh_del' + ).encode()) + + def test_success_200(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(200) + ok, msg = client.delete_object("/uri") + self.assertTrue(ok) + + def test_success_204(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(204) + ok, msg = client.delete_object("/uri") + self.assertTrue(ok) + + def test_failure(self): + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(500, text="Cannot delete") + with self.assertRaises(DeleteError): + client.delete_object("/uri") + + def test_ddic_lock_uses_special_accept_header(self): + """delete 调用 lock 时传 DDIC lock 专用 Accept 头(缺陷4)。 + + DDIC 对象(domain/dataelement/table/structure)的 lock 端点对默认 + Accept: */* 返回 HTTP 406。参考 abap-adt-api objectcontents.ts 的 lock + 实现,需用 application/*,application/vnd.sap.as+xml;charset=UTF-8; + dataname=com.sap.adt.lock.result。 + """ + client = _make_client() + self._mock_lock_ok(client) + client.session.delete.return_value = _mock_resp(204) + client.delete_object("/uri") + # lock 的 POST 请求必须携带 DDIC lock 专用 Accept 头。 + # 注意:delete 成功后会再发一次 unlock 的 POST,故不能用 call_args + # (那是最后一次调用 = unlock),须从调用列表中挑出 _action=LOCK 的请求。 + lock_calls = [ + kw for _, kw in client.session.post.call_args_list + if kw.get("params", {}).get("_action") == "LOCK" + ] + self.assertEqual(len(lock_calls), 1, "delete 应恰好发起一次 LOCK 请求") + accept = lock_calls[0]["headers"]["Accept"] + self.assertIn("com.sap.adt.lock.result", accept) + self.assertNotEqual(accept, "*/*") + + +# ═══════════════════════════════════════════ +# 创建对象 +# ═══════════════════════════════════════════ + +class TestCreateObject(unittest.TestCase): + + def test_report_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, headers={ + "Location": "/sap/bc/adt/programs/programs/ztest" + }) + obj_uri, src_uri = client.create_object("report", "ZTEST", "Test report") + self.assertIn("ztest", obj_uri) + + def test_class_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201, headers={ + "Location": "/sap/bc/adt/oo/classes/zcl_test" + }) + obj_uri, src_uri = client.create_object("class", "ZCL_TEST", "Test class") + self.assertIn("zcl_test", obj_uri) + + def test_with_source(self): + """有 source → lock → set_source → unlock → activate。""" + client = _make_client() + lock_resp = _mock_resp(200, content=( + f'lh' + ).encode()) + create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/programs/programs/ztest"}) + unlock_resp = _mock_resp(200) + activate_resp = _mock_resp(200, text="", content=b"") + client.session.post.side_effect = [create_resp, lock_resp, unlock_resp, activate_resp] + client.session.put.return_value = _mock_resp(200) + obj_uri, src_uri = client.create_object("report", "ZTEST", "Test", source="REPORT ztest.") + self.assertIn("ztest", obj_uri) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(409, text="Already exists") + with self.assertRaises(CreateError): + client.create_object("report", "ZTEST", "Test") + + +class TestBuildCreateBody(unittest.TestCase): + + def test_report(self): + body = _make_client()._build_create_body("report", "ZTEST", "Desc") + self.assertIn("ZTEST", body) + + def test_class(self): + body = _make_client()._build_create_body("class", "ZCL_TEST", "Desc") + self.assertIn("ZCL_TEST", body) + + def test_unsupported(self): + with self.assertRaises(ValueError): + _make_client()._build_create_body("unknown", "ZTEST", "Desc") + + +# ═══════════════════════════════════════════ +# DDIC 对象 — definition_body 是 XML 字符串 +# ═══════════════════════════════════════════ + +_DDIC_DOMAIN_XML = ( + f'' + f'' + f'' + f'' +) + +class TestCreateDDICObject(unittest.TestCase): + + def test_domain_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + # mock activate(create_ddic_object 内部调用) + client.session.post.side_effect = [ + _mock_resp(201), # create POST + _mock_resp(200, text="", content=b""), # activate POST + ] + obj_uri, src_uri = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("ztest_dom", obj_uri) + + def test_table_success(self): + """table 类型先 POST 创建实体,再 PUT 写入 DDL(缺陷3 修复后)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + # mock _put_ddl_source + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + obj_uri, src_uri = client.create_ddic_object( + "table", "ZTEST_TAB", "" + ) + # 先 POST 创建实体,再 PUT DDL + self.assertTrue(client.session.post.called) + mock_ddl.assert_called_once() + self.assertIn("ztest_tab", obj_uri) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_ddic_object("domain", "ZTEST", _DDIC_DOMAIN_XML) + + +# ═══════════════════════════════════════════ +# 对象状态 +# ═══════════════════════════════════════════ + +class TestGetObjectStatus(unittest.TestCase): + + def test_active(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertTrue(client.get_object_status("/uri")["exists"]) + + def test_not_exists(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.get_object_status("/uri")["exists"]) + + +# ═══════════════════════════════════════════ +# 函数组 +# ═══════════════════════════════════════════ + +class TestFunctionGroup(unittest.TestCase): + + def test_exists_true(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200) + self.assertTrue(client.function_group_exists("ZGROUP")) + + def test_exists_false(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertFalse(client.function_group_exists("ZGROUP")) + + def test_create_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_function_group("ZGROUP")) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_function_group("ZGROUP") + + +# ═══════════════════════════════════════════ +# 搜索浏览 +# ═══════════════════════════════════════════ + +_LIST_TWO = ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' +).encode() + +_LIST_EMPTY = ( + f'' + f'' +).encode() + + +class TestListObjects(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_LIST_TWO) + result = client.list_objects(obj_type="report") + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "Z_TEST1") + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.list_objects(), []) + + +class TestWhereUsed(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + result = client.where_used("Z_TEST", "/uri", "PROG/P") + self.assertEqual(len(result), 1) + + def test_empty(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.where_used("Z", "/uri"), []) + + +class TestSearchCode(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertEqual(len(client.search_code("SELECT", obj_type="report")), 1) + + def test_empty(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_LIST_EMPTY) + self.assertEqual(client.search_code("NONEXISTENT"), []) + + +class TestReadSourceForDiff(unittest.TestCase): + + def test_success(self): + client = _make_client() + with patch.object(client, "get_source", return_value="REPORT ztest."): + self.assertEqual(client.read_source_for_diff("ZTEST", "report"), "REPORT ztest.") + + def test_no_src_uri_raises(self): + client = _make_client() + with self.assertRaises(SapCliError): + client.read_source_for_diff("ZTEST", "functiongroup") + + +# ═══════════════════════════════════════════ +# CDS +# ═══════════════════════════════════════════ + +class TestGetCdsSource(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, text="define view Z_TEST as select from mara { mara.matnr };") + self.assertIn("define view", client.get_cds_source("Z_TEST")) + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_cds_source("NONEXISTENT") + + +class TestCreateCDS(unittest.TestCase): + + def test_success(self): + client = _make_client() + lock_resp = _mock_resp(200, content=( + f'lh_cds' + ).encode()) + create_resp = _mock_resp(201, headers={"Location": "/sap/bc/adt/dds/ddl/sources/z_test"}) + unlock_resp = _mock_resp(200) + client.session.post.side_effect = [create_resp, lock_resp, unlock_resp] + client.session.put.return_value = _mock_resp(200) + obj_uri, src_uri = client.create_cds("Z_TEST", "Test", "define view Z_TEST as select from mara { mara.matnr };") + self.assertIn("z_test", obj_uri) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_cds("Z_TEST", "Test", "ddl source") + + +# ═══════════════════════════════════════════ +# 包管理 +# ═══════════════════════════════════════════ + +class TestPackage(unittest.TestCase): + + def test_create_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST_PKG")) + + def test_create_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(CreateError): + client.create_package("Z_TEST_PKG") + + def test_get_info_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'Test' + ).encode()) + self.assertIsInstance(client.get_package_info("Z_TEST"), dict) + + def test_get_info_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_package_info("NONEXISTENT") + + +# ═══════════════════════════════════════════ +# 传输管理 +# ═══════════════════════════════════════════ + +class TestTransportInfo(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + result = client.transport_info("DEVK001") + self.assertEqual(result["number"], "DEVK001") + + def test_not_found(self): + client = _make_client() + mock = _mock_resp(404) + mock.raise_for_status.side_effect = Exception("404") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.transport_info("NONEXISTENT") + + +class TestTransportRelease(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200) + self.assertTrue(client.transport_release("DEVK001")) + + def test_failure(self): + client = _make_client() + client.session.post.return_value = _mock_resp(500, text="Error") + with self.assertRaises(SapCliError): + client.transport_release("DEVK001") + + +class TestTransportObjects(unittest.TestCase): + + def test_found(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + self.assertIsInstance(client.transport_objects("DEVK001"), list) + + def test_empty(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + self.assertEqual(client.transport_objects("DEVK001"), []) + + +# ═══════════════════════════════════════════ +# 代码质量 +# ═══════════════════════════════════════════ + +class TestATCCheck(unittest.TestCase): + + def test_clean(self): + client = _make_client() + xml_body = ( + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml_body, content=xml_body.encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertTrue(ok) + + def test_with_findings(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'Unused variable' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertFalse(ok) + + +class TestPrettyPrint(unittest.TestCase): + + def test_success(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="FORMATTED CODE") + self.assertEqual(client.pretty_print("raw code"), "FORMATTED CODE") + + def test_error(self): + """pretty_print 用 raise_for_status() 抛 HTTPError。""" + client = _make_client() + mock = _mock_resp(500, text="Error") + mock.raise_for_status.side_effect = Exception("500 Server Error") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.pretty_print("raw code") + + +# ═══════════════════════════════════════════ +# _build_create_body 各类型 +# ═══════════════════════════════════════════ + +class TestBuildCreateBodyTypes(unittest.TestCase): + + def _check(self, obj_type, name): + body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}") + self.assertIn(name, body) + + def test_report(self): self._check("report", "ZTEST") + def test_class(self): self._check("class", "ZCL_TEST") + def test_interface(self): self._check("interface", "ZIF_TEST") + def test_domain(self): self._check("domain", "ZTEST_DOM") + def test_dataelement(self): self._check("dataelement", "ZTEST_DE") + def test_table(self): self._check("table", "ZTEST_TAB") + def test_structure(self): self._check("structure", "ZTEST_STR") + def test_tabletype(self): self._check("tabletype", "ZTEST_TT") + + def test_unsupported(self): + with self.assertRaises(ValueError): + _make_client()._build_create_body("bogus_type", "ZTEST", "Test") + + +# ═══════════════════════════════════════════ +# set_source 错误重试路径(行 59-69) +# ═══════════════════════════════════════════ + +class TestSetSourceRetry(unittest.TestCase): + + def _error_body(self, corrnr): + return f'{corrnr}' + + def test_retry_with_extracted_corrnr_succeeds(self): + """首次 400 + 错误体含不同 corrNr → 用该 corrNr 重试成功。""" + client = _make_client() + client.session.put.side_effect = [ + _mock_resp(400, text=self._error_body("DEVK999")), + _mock_resp(200), + ] + self.assertTrue(client.set_source("/src", "code", "lh", corr_nr="DEVK001")) + self.assertEqual(client.session.put.call_count, 2) + + def test_retry_fails_raises(self): + """重试后仍 400 → raise_for_status。""" + client = _make_client() + first = _mock_resp(400, text=self._error_body("DEVK999")) + retry = _mock_resp(400) + retry.raise_for_status.side_effect = Exception("still 400") + client.session.put.side_effect = [first, retry] + with self.assertRaises(Exception): + client.set_source("/src", "code", "lh", corr_nr="DEVK001") + + def test_no_corrnr_in_error_raises(self): + """错误体无 corrNr → 不重试,直接 raise。""" + client = _make_client() + resp = _mock_resp(400, text="boom") + resp.raise_for_status.side_effect = Exception("400") + client.session.put.return_value = resp + with self.assertRaises(Exception): + client.set_source("/src", "code", "lh") + self.assertEqual(client.session.put.call_count, 1) + + +# ═══════════════════════════════════════════ +# lock 高级路径(行 94, 101-107) +# ═══════════════════════════════════════════ + +class TestLockAdvanced(unittest.TestCase): + + def test_lock_with_corr_nr(self): + """传 corr_nr → 写入 params 并原样返回(不提取)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'lh_123' + ).encode()) + handle, corr = client.lock("/uri", corr_nr="DEVK001") + self.assertEqual(handle, "lh_123") + self.assertEqual(corr, "DEVK001") + + def test_lock_500_retry_with_existing_corrnr(self): + """首次 500 + 错误体含 corrNr → 用该 corrNr 重试成功。""" + client = _make_client() + error_body = 'DEVK999' + client.session.post.side_effect = [ + _mock_resp(500, text=error_body), + _mock_resp(200, content=( + f'lh' + ).encode()), + ] + handle, corr = client.lock("/uri", corr_nr="DEVK001") + self.assertEqual(handle, "lh") + self.assertEqual(corr, "DEVK999") + self.assertEqual(client.session.post.call_count, 2) + + +# ═══════════════════════════════════════════ +# _extract_locked_corrnr(行 135-149) +# ═══════════════════════════════════════════ + +class TestExtractLockedCorrnr(unittest.TestCase): + + def test_key_corrnr(self): + client = _make_client() + body = 'DEVK001' + self.assertEqual(client._extract_locked_corrnr(body), "DEVK001") + + def test_star_skipped_no_message(self): + client = _make_client() + body = '*' + self.assertIsNone(client._extract_locked_corrnr(body)) + + def test_message_branch(self): + client = _make_client() + body = 'Object is locked in request DEVK888' + self.assertEqual(client._extract_locked_corrnr(body), "DEVK888") + + def test_invalid_xml(self): + client = _make_client() + self.assertIsNone(client._extract_locked_corrnr("not valid xml <<")) + + +# ═══════════════════════════════════════════ +# _extract_corrnr_from_lock_response(行 166, 172-175, 183-185) +# ═══════════════════════════════════════════ + +class TestExtractCorrnrFromLockResponse(unittest.TestCase): + + def _extract(self, xml): + return _make_client()._extract_corrnr_from_lock_response(ET.fromstring(xml)) + + def test_strategy1_cornn_tag(self): + self.assertEqual( + self._extract('DEVK001'), + "DEVK001", + ) + + def test_strategy1_star_skipped(self): + self.assertIsNone( + self._extract('*'), + ) + + def test_strategy2_attr(self): + self.assertEqual( + self._extract(''), + "DEVK003", + ) + + def test_strategy2_tagname(self): + self.assertEqual( + self._extract('DEVK004'), + "DEVK004", + ) + + def test_strategy3_property_key(self): + self.assertEqual( + self._extract('DEVK005'), + "DEVK005", + ) + + def test_no_match_returns_none(self): + self.assertIsNone(self._extract('val')) + + +# ═══════════════════════════════════════════ +# activate — inactivectsobjects 路径(行 235-265) +# ═══════════════════════════════════════════ + +class TestActivateInactiveCtsObjects(unittest.TestCase): + + IOC_NS = "http://www.sap.com/abapxml/inactiveCtsObjects" + + def _activate_resp(self, xml): + return _mock_resp( + 200, text=xml, content=xml.encode(), + headers={"content-type": "application/vnd.sap.adt.inactivectsobjects+xml"}, + ) + + def test_with_objects_via_fallback_ref(self): + """ref 不在 adtcore ns → 走 endswith}ref 回退路径。""" + xml = ( + f'' + f'' + f'' + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(any("Z_INACTIVE" in m["text"] for m in msgs)) + + def test_with_objects_via_adtcore_ref(self): + """ref 在 adtcore ns → obj_elem.find 直接命中。""" + xml = ( + f'' + f'' + f'' + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(any("Z_ADTC" in m["text"] for m in msgs)) + + def test_empty_no_objects(self): + """inactivectsobjects 响应但无 entry → 视为成功。""" + xml = ( + f'' + f'' + f'' + ) + client = _make_client() + client.session.post.return_value = self._activate_resp(xml) + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + +# ═══════════════════════════════════════════ +# activate — corr_nr / msg 回退 / verify 异常(行 219, 316-317, 334-337) +# ═══════════════════════════════════════════ + +class TestActivateMisc(unittest.TestCase): + + def test_activate_with_corr_nr(self): + """传 corr_nr → 写入 activation params。""" + client = _make_client() + xml = f'' + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.activate("ztest", "/uri", corr_nr="DEVK001") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + def test_msg_without_txt_uses_fallback_text(self): + """msg 无 }txt 子元素 → 用第一个长文本后代(行 334-337)。""" + client = _make_client() + xml = ( + f'' + f'' + f'' + f'Some long activation failure text' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.activate("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(msgs) + self.assertIn("Some long", msgs[0]["text"]) + + def test_empty_response_verify_raises(self): + """空响应 + verify GET 抛异常 → 捕获后视为成功(行 316-317)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="", content=b"") + client.session.get.side_effect = Exception("verify boom") + ok, msgs = client.activate("ztest", "/uri") + self.assertTrue(ok) + self.assertEqual(msgs, []) + + +# ═══════════════════════════════════════════ +# syntax_check — msg 回退文本(行 390-393) +# ═══════════════════════════════════════════ + +class TestSyntaxCheckMsgFallback(unittest.TestCase): + + def test_msg_without_txt_uses_fallback_text(self): + client = _make_client() + xml = ( + f'' + f'' + f'' + f'Another long syntax failure note' + f'' + f'' + ) + client.session.post.return_value = _mock_resp(200, text=xml, content=xml.encode()) + ok, msgs = client.syntax_check("ztest", "/uri") + self.assertFalse(ok) + self.assertTrue(msgs) + self.assertIn("Another long", msgs[0]["text"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_clone.py b/tests/unit/test_clone.py new file mode 100644 index 0000000..90a8a22 --- /dev/null +++ b/tests/unit/test_clone.py @@ -0,0 +1,322 @@ +"""commands/clone.py 单元测试 — cmd_clone + _make_profile_client。 + +clone 命令从一个系统下载对象,上传到另一个系统。client 参数被忽略; +源/目标客户端分别按 --from / --to profile 构建。 + +运行: python tests/unit/test_clone.py +""" +from __future__ import annotations + +import argparse +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + + +def _args(**kwargs): + defaults = { + "name": "ZTEST", + "type": "report", + "from_profile": "DEV", + "to_profile": "QA", + "corr_nr": None, + "config": None, + "verify_ssl": False, + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# ═══════════════════════════════════════════ +# _make_profile_client +# ═══════════════════════════════════════════ + +class TestMakeProfileClient(unittest.TestCase): + """_make_profile_client 按 profile 加载配置并建立已登录的 ADT 客户端。""" + + @patch("sapcli.commands.clone.ADTClient") + @patch("sapcli.commands.clone.load_config") + def test_builds_and_logs_in(self, mock_load, mock_client_cls): + from sapcli.commands.clone import _make_profile_client + + sap_cfg = MagicMock() + sap_cfg.host = "h" + sap_cfg.client = "100" + sap_cfg.user = "u" + sap_cfg.password = "p" + mock_load.return_value = (sap_cfg, "DEV") + + inner = MagicMock() + mock_client_cls.return_value = inner + + result = _make_profile_client("/path.ini", "DEV", verify_ssl=True) + + mock_load.assert_called_once_with("/path.ini", profile="DEV") + mock_client_cls.assert_called_once_with( + "h", "100", "u", "p", verify_ssl=True, + ) + inner.login.assert_called_once() + self.assertIs(result, inner) + + @patch("sapcli.commands.clone.ADTClient") + @patch("sapcli.commands.clone.load_config") + def test_default_verify_ssl_false(self, mock_load, mock_client_cls): + from sapcli.commands.clone import _make_profile_client + + mock_load.return_value = (MagicMock(), "QA") + mock_client_cls.return_value = MagicMock() + + _make_profile_client(None, "QA") + + # 未传 verify_ssl 时默认 False + self.assertEqual(mock_client_cls.call_args[1].get("verify_ssl"), False) + + +# ═══════════════════════════════════════════ +# cmd_clone +# ═══════════════════════════════════════════ + +class TestCmdClone(unittest.TestCase): + """cmd_clone 跨系统克隆对象。""" + + def test_no_source_type_raises_invalid_name(self): + """functiongroup 无源码 → InvalidNameError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import InvalidNameError + + with self.assertRaises(InvalidNameError): + cmd_clone(_args(type="functiongroup", name="ZFG"), MagicMock()) + + @patch("sapcli.commands.clone._make_profile_client") + def test_source_connection_failure_raises_config_error(self, mock_make): + """源系统连接失败 → ConfigError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ConfigError + + mock_make.side_effect = ConnectionError("host unreachable") + + with patch("builtins.print"): + with self.assertRaises(ConfigError) as ctx: + cmd_clone(_args(), None) + self.assertIn("源系统", str(ctx.exception)) + + @patch("sapcli.commands.clone._make_profile_client") + def test_source_object_not_found_raises(self, mock_make): + """源对象不存在 → ObjectNotFoundError,且关闭源 session。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ObjectNotFoundError + + src_client = MagicMock() + src_client.object_exists.return_value = False + mock_make.return_value = src_client + + with patch("builtins.print"): + with self.assertRaises(ObjectNotFoundError): + cmd_clone(_args(), None) + # 即使失败也关闭源 session(finally 块) + src_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_success_target_exists_normalizes_source(self, mock_make, mock_sync): + """目标对象已存在 → 同步成功;源码 \r\n 归一化为 \n 并写入临时文件。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest.\r\nWRITE 'hi'.\r\n" + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + + captured = {} + + def fake_sync(name, obj_type, path, client, corr_nr=None): + with open(path, "r", encoding="utf-8") as f: + captured["content"] = f.read() + captured["client"] = client + captured["corr_nr"] = corr_nr + return (True, None, corr_nr or "DEVK001") + + mock_sync.side_effect = fake_sync + + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK901362"), None) + + # 源码换行归一化 + self.assertEqual(captured["content"], "REPORT ztest.\nWRITE 'hi'.\n") + # _sync_single 收到的是目标客户端 + self.assertIs(captured["client"], tgt_client) + self.assertEqual(captured["corr_nr"], "DEVK901362") + # 两个 session 都关闭 + src_client.session.close.assert_called_once() + tgt_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_success_target_not_exists(self, mock_make, mock_sync): + """目标对象不存在时打印「将创建并同步」并继续。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = False + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, None) + + with patch("builtins.print") as mock_print: + cmd_clone(_args(), None) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("将创建并同步", printed) + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_sync_failure_raises_sapcli_error(self, mock_make, mock_sync): + """_sync_single 返回失败 → SapCliError。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import SapCliError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (False, "激活失败: 行3", None) + + with patch("builtins.print"): + with self.assertRaises(SapCliError) as ctx: + cmd_clone(_args(), None) + self.assertIn("激活失败", str(ctx.exception)) + tgt_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_sync_failure_with_empty_error_uses_default(self, mock_make, mock_sync): + """_sync_single 返回失败但 error_msg 为空 → 使用默认消息。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import SapCliError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (False, "", None) + + with patch("builtins.print"): + with self.assertRaises(SapCliError) as ctx: + cmd_clone(_args(name="ZMYCLS", type="class"), None) + self.assertIn("ZMYCLS", str(ctx.exception)) + + @patch("sapcli.commands.clone._make_profile_client") + def test_target_connection_failure_raises_config_error(self, mock_make): + """目标系统连接失败 → ConfigError(源已成功连接并下载)。""" + from sapcli.commands.clone import cmd_clone + from sapcli.exceptions import ConfigError + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + mock_make.side_effect = [src_client, ConnectionError("target down")] + + with patch("builtins.print"): + with self.assertRaises(ConfigError) as ctx: + cmd_clone(_args(), None) + self.assertIn("目标系统", str(ctx.exception)) + # 源 session 已关闭 + src_client.session.close.assert_called_once() + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_no_corr_nr_omits_transport_line(self, mock_make, mock_sync): + """未指定 corr_nr 时不打印「目标传输请求」行。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, None) + + with patch("builtins.print") as mock_print: + cmd_clone(_args(corr_nr=None), None) + + # 同步成功;actual_corr 为 None 时不打印目标传输请求 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("克隆完成", printed) + + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone._make_profile_client") + def test_temp_file_cleaned_up(self, mock_make, mock_sync): + """同步成功后临时文件被删除。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + + captured = {} + + def fake_sync(name, obj_type, path, client, corr_nr=None): + captured["path"] = path + return (True, None, "DEVK001") + + mock_sync.side_effect = fake_sync + + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK001"), None) + + # 同步返回后,临时文件应已删除 + self.assertFalse(os.path.isfile(captured["path"])) + + @patch("sapcli.commands.clone._make_profile_client") + @patch("sapcli.commands.clone._sync_single") + @patch("sapcli.commands.clone.os.unlink", side_effect=OSError("busy")) + def test_temp_file_unlink_error_swallowed(self, mock_unlink, mock_sync, mock_make): + """os.unlink 抛 OSError 时被吞掉,不影响主流程。""" + from sapcli.commands.clone import cmd_clone + + src_client = MagicMock() + src_client.object_exists.return_value = True + src_client.get_source.return_value = "REPORT ztest." + + tgt_client = MagicMock() + tgt_client.object_exists.return_value = True + + mock_make.side_effect = [src_client, tgt_client] + mock_sync.return_value = (True, None, "DEVK001") + + # 不应抛异常(OSError 在 finally 中被吞) + with patch("builtins.print"): + cmd_clone(_args(corr_nr="DEVK001"), None) + mock_unlink.assert_called_once() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py new file mode 100644 index 0000000..c8cfd48 --- /dev/null +++ b/tests/unit/test_commands.py @@ -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'' + b'' +) + +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) diff --git a/tests/unit/test_commands_extra.py b/tests/unit/test_commands_extra.py new file mode 100644 index 0000000..08b2b7f --- /dev/null +++ b/tests/unit/test_commands_extra.py @@ -0,0 +1,1901 @@ +"""commands/ 模块深入单元测试 — 覆盖失败/边界场景。 + +运行: python tests/unit/test_commands_extra.py +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch, mock_open + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient + + +# ── helpers ────────────────────────────────────────────────────── + +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(spec=ADTClient) + 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 + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +# 用于 cmd_info 的 XML 响应 +_INFO_XML = ( + b'' + b'') + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_download +# ═══════════════════════════════════════════════════════════════ + +class TestCmdDownloadObjectNotFound(unittest.TestCase): + """cmd_download 对象不存在时报错。""" + + def test_object_not_found_raises(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) as ctx: + cmd_download(_args(path=td), client) + self.assertIn("ZTEST", str(ctx.exception)) + + def test_object_not_found_prints_hint(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 patch("builtins.print") as mock_print: + with self.assertRaises(ObjectNotFoundError): + cmd_download(_args(path=td), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不存在", printed) + + +class TestCmdDownloadFileWriteFailure(unittest.TestCase): + """cmd_download 文件写入失败(权限/路径不可写)。""" + + def test_write_permission_error(self): + from sapcli.commands.crud import cmd_download + + client = _make_client() + client.object_exists.return_value = True + client.get_source.return_value = "REPORT ztest." + + # 令 open() 抛出 PermissionError + with patch("builtins.open", side_effect=PermissionError("拒绝访问")): + with self.assertRaises(PermissionError): + cmd_download(_args(path="/tmp/some_dir"), client) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_create +# ═══════════════════════════════════════════════════════════════ + +class TestCmdCreateWithSourceFile(unittest.TestCase): + """cmd_create 有 --source 文件时。""" + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_reads_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") + + source_code = "REPORT ztest.\nWRITE: / 'hello'." + with tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) as f: + f.write(source_code) + tmpfile = f.name + try: + cmd_create( + _args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), + client, + ) + # create_object 的 source 参数应该来自文件内容 + call_args = client.create_object.call_args + self.assertEqual(call_args[0][4], source_code) + finally: + os.unlink(tmpfile) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_with_class_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/zcl_test", "/uri/zcl_test/source/main") + + source_code = "CLASS zcl_test DEFINITION PUBLIC. ENDCLASS." + with tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) as f: + f.write(source_code) + tmpfile = f.name + try: + cmd_create( + _args( + type="class", + name="ZCL_TEST", + source=tmpfile, + description="Test Class", + package="$TMP", + corr_nr=None, + ), + client, + ) + call_args = client.create_object.call_args + self.assertEqual(call_args[0][4], source_code) + finally: + os.unlink(tmpfile) + + +class TestCmdCreateWithDefinitionFile(unittest.TestCase): + """cmd_create 有 --definition 文件时(DDIC 类型)。""" + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_domain_with_definition(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") + + # 创建一个临时 JSON 定义文件 + definition = {"datatype": "CHAR", "length": 10, "decimals": 0} + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="domain", + name="ZTEST_DOM", + source=None, + definition=def_file, + description="Test Domain", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_dataelement_with_definition(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_de", "/uri/ztest_de/source/main") + + definition = {"datatype": "CHAR", "length": 20, "domain_name": "ZTEST_DOM"} + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="dataelement", + name="ZTEST_DE", + source=None, + definition=def_file, + description="Test DE", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_table_with_definition(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_tab", "/uri/ztest_tab/source/main") + + definition = { + "fields": [ + {"name": "KEY_FIELD", "type": "char10", "key": True, "not_null": True}, + {"name": "VALUE", "type": "char20"}, + ], + "table_category": "#TRANSPARENT", + "delivery_class": "#A", + } + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(definition, f) + def_file = f.name + try: + cmd_create( + _args( + type="table", + name="ZTEST_TAB", + source=None, + definition=def_file, + description="Test Table", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(def_file) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_tabletype_requires_definition(self, mock_transport): + """tabletype 没有 definition 时应报错。""" + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import CreateError + + client = _make_client() + client.object_exists.return_value = False + + with self.assertRaises(CreateError): + cmd_create( + _args( + type="tabletype", + name="ZTEST_TT", + source=None, + definition=None, + description="Test TT", + package="$TMP", + corr_nr=None, + ), + client, + ) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_functiongroup_success(self, MockManifest, mock_transport): + from sapcli.commands.crud import cmd_create + + client = _make_client() + client.function_group_exists.return_value = False + client.create_function_group.return_value = "/uri/ztest_fg" + + cmd_create( + _args( + type="functiongroup", + name="ZTEST_FG", + source=None, + description="Test FG", + package="$TMP", + corr_nr=None, + ), + client, + ) + client.create_function_group.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_functiongroup_already_exists(self, mock_transport): + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import ObjectAlreadyExistsError + + client = _make_client() + client.function_group_exists.return_value = True + + with self.assertRaises(ObjectAlreadyExistsError): + cmd_create( + _args( + type="functiongroup", + name="ZTEST_FG", + source=None, + description="Test FG", + package="$TMP", + corr_nr=None, + ), + client, + ) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_info +# ═══════════════════════════════════════════════════════════════ + +class TestCmdInfoObjectNotFound(unittest.TestCase): + """cmd_info 对象不存在(HTTP 404)。""" + + def test_info_404_raises(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) as ctx: + cmd_info(_args(), client) + self.assertIn("ZTEST", str(ctx.exception)) + + def test_info_500_raises(self): + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import SapCliError + + client = _make_client() + client.session.get.return_value = _mock_resp(500, text="Internal Server Error") + + with self.assertRaises(SapCliError) as ctx: + cmd_info(_args(), client) + self.assertIn("500", str(ctx.exception)) + + def test_info_name_mismatch(self): + """返回的名称与请求名称不匹配。""" + from sapcli.commands.crud import cmd_info + from sapcli.exceptions import SapCliError + + mismatched_xml = ( + b'' + b'' + ) + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=mismatched_xml) + + with self.assertRaises(SapCliError) as ctx: + cmd_info(_args(), client) + self.assertIn("不匹配", str(ctx.exception)) + + def test_info_http_406_fallback(self): + """HTTP 406 时回退到 application/xml。""" + from sapcli.commands.crud import cmd_info + + client = _make_client() + # 第一次 406,第二次 200 + client.session.get.side_effect = [ + _mock_resp(406), + _mock_resp(200, content=_INFO_XML), + ] + # 应该不抛异常 + cmd_info(_args(), client) + self.assertEqual(client.session.get.call_count, 2) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_info — 传输请求号字段(纯只读查 E071) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdInfoTransport(unittest.TestCase): + """cmd_info 传输请求号字段。""" + + @staticmethod + def _xml(name="ZTEST", type_code="PROG/P"): + return ( + b'' + b'' + ) + + def test_request_found(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.return_value = { + "rows": [["DEVK901362"], ["DEVK901400"]] + } + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("DEVK901362", printed) + self.assertIn("DEVK901400", printed) + + def test_no_request_shows_none(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("传输请求: 无", printed) + + def test_query_failure_graceful(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=self._xml()) + client.query_table_data.side_effect = Exception("boom") + # 不应抛异常 + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("(无法获取)", printed) + + def test_non_r3tr_type_skips_query(self): + """include 非独立 R3TR 对象,不查 E071。""" + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp( + 200, content=self._xml(type_code="PROG/I") + ) + with patch("builtins.print") as mock_print: + cmd_info(_args(type="include", name="ZTEST"), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("非独立传输对象", printed) + client.query_table_data.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_list(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdListNoResults(unittest.TestCase): + """cmd_list 无结果。""" + + def test_list_empty_results(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_list_api_error(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.side_effect = Exception("Network error") + + # 应不抛异常,只打印错误 + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_list_with_results(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [ + {"name": "Z_PROG1", "type": "PROG/P", "package": "$TMP", "description": "Test 1"}, + {"name": "Z_PROG2", "type": "PROG/P", "package": "$TMP", "description": "Test 2"}, + ] + + with patch("builtins.print") as mock_print: + cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + + def test_list_with_package_filter(self): + from sapcli.commands.search import cmd_list + + client = _make_client() + client.list_objects.return_value = [] + + cmd_list( + argparse.Namespace(type="report", package="Z_MY_PKG", prefix=None, config=None), + client, + ) + # 检查 list_objects 被调用时包含 package 参数 + call_kwargs = client.list_objects.call_args[1] + self.assertEqual(call_kwargs.get("package"), "Z_MY_PKG") + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_whereused(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdWhereUsedNoReferences(unittest.TestCase): + """cmd_whereused 无引用。""" + + def test_whereused_empty(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到引用", printed) + + def test_whereused_api_error(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.side_effect = Exception("Connection refused") + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_whereused_with_results(self): + from sapcli.commands.search import cmd_whereused + + client = _make_client() + client.where_used.return_value = [ + {"name": "Z_USER1", "type": "PROG/P", "package": "$TMP", "uri": "/prog/z_user1"}, + ] + + with patch("builtins.print") as mock_print: + cmd_whereused(_args(), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("1", printed) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_search(来自 search.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdSearchNoResults(unittest.TestCase): + """cmd_search 无结果。""" + + def test_search_empty(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_search_api_error(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.side_effect = Exception("Timeout") + + with patch("builtins.print") as mock_print: + cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_search_with_type_filter(self): + from sapcli.commands.search import cmd_search + + client = _make_client() + client.search_code.return_value = [ + {"name": "Z_RESULT", "type": "PROG/P", "description": "found"}, + ] + + cmd_search( + argparse.Namespace(query="WRITE", type="report", config=None), + client, + ) + client.search_code.assert_called_once() + # search_code 被调用时传入了 obj_type 参数 + call_args = client.search_code.call_args + # 它是 (query, obj_type=adt_type) 形式 + obj_type_arg = call_args[1].get("obj_type") + if obj_type_arg is None and len(call_args[0]) > 1: + obj_type_arg = call_args[0][1] + self.assertIsNotNone(obj_type_arg) + + +# ═══════════════════════════════════════════════════════════════ +# crud.py — cmd_format(来自 quality.py) +# ═══════════════════════════════════════════════════════════════ + +class TestCmdFormatSuccess(unittest.TestCase): + """cmd_format 格式化成功(完整流程)。""" + + def test_format_full_success(self): + from sapcli.commands.quality import cmd_format + + client = _make_client() + client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'." + client.pretty_print.return_value = "REPORT ztest.\n WRITE: / 'hello'." + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("格式化完成", printed) + client.pretty_print.assert_called_once() + client.set_source.assert_called_once() + client.activate.assert_called_once() + + def test_format_no_change(self): + """格式化结果与原始一致,不写回。""" + from sapcli.commands.quality import cmd_format + + client = _make_client() + original = "REPORT ztest.\nWRITE: / 'hello'." + client.get_source.return_value = original + client.pretty_print.return_value = original # 无变化 + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无需修改", printed) + client.set_source.assert_not_called() + + def test_format_get_source_fails(self): + from sapcli.commands.quality import cmd_format + + client = _make_client() + client.get_source.side_effect = Exception("Read error") + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST", type="report", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_format_unsupported_type(self): + """functiongroup 没有 src_uri,应提前返回。""" + from sapcli.commands.quality import cmd_format + + client = _make_client() + + with patch("builtins.print") as mock_print: + cmd_format( + argparse.Namespace(name="ZTEST_FG", type="functiongroup", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不支持", printed) + client.get_source.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds download +# ═══════════════════════════════════════════════════════════════ + +class TestCdsDownload(unittest.TestCase): + """cds download 成功。""" + + def test_cds_download_success(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + ddl_source = "define view z_test as select from sflight { carrid, connid, fldate };" + client.get_cds_source.return_value = ddl_source + + with tempfile.TemporaryDirectory() as td: + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), + client, + ) + expected_file = os.path.join(td, "z_test.ddl") + self.assertTrue(os.path.isfile(expected_file)) + with open(expected_file, "r", encoding="utf-8") as f: + content = f.read() + self.assertEqual(content, ddl_source) + + def test_cds_download_api_error(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.get_cds_source.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_MISSING", path=".", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_cds_download_creates_dir(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 { matnr };" + + with tempfile.TemporaryDirectory() as td: + subdir = os.path.join(td, "new_dir") + cmd_cds( + argparse.Namespace(cds_action="download", name="Z_TEST", path=subdir, config=None), + client, + ) + self.assertTrue(os.path.isfile(os.path.join(subdir, "z_test.ddl"))) + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds create +# ═══════════════════════════════════════════════════════════════ + +class TestCdsCreate(unittest.TestCase): + """cds create 有 ddl_path 时。""" + + def test_cds_create_with_ddl_file(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + + ddl_content = "@AbapCatalog.sqlViewName: 'ZTEST'\ndefine view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with tempfile.TemporaryDirectory() as td: + cmd_cds( + argparse.Namespace( + cds_action="create", + name="Z_TEST", + description="Test CDS", + ddl_path=ddl_file, + path=td, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + # 验证 CDS 创建时传入了文件内容 + call_args = client.create_cds.call_args + self.assertIn(ddl_content, call_args[0]) + finally: + os.unlink(ddl_file) + + def test_cds_create_default_template(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", + ddl_path=None, + path=td, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + # 检查默认模板被使用 + call_args = client.create_cds.call_args + ddl_arg = call_args[0][2] + self.assertIn("define view", ddl_arg) + + def test_cds_create_failure(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.create_cds.side_effect = Exception("Already exists") + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="create", + name="Z_DUP", + description="Duplicate", + ddl_path=None, + path=".", + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# cds.py — cds sync +# ═══════════════════════════════════════════════════════════════ + +class TestCdsSync(unittest.TestCase): + """cds sync 成功。""" + + def test_cds_sync_existing_object(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = (True, []) + + ddl_content = "define view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + client.lock.assert_called_once() + client.set_source.assert_called_once() + client.activate.assert_called_once() + finally: + os.unlink(ddl_file) + + def test_cds_sync_nonexistent_object_creates(self): + """CDS 不存在时自动创建。""" + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = False + client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main") + + ddl_content = "define view z_test as select from sflight { carrid };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + client.create_cds.assert_called_once() + finally: + os.unlink(ddl_file) + + def test_cds_sync_ddl_file_not_found(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path="/nonexistent/file.ddl", + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("不存在", printed) + + def test_cds_sync_activation_failure(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.activate.return_value = ( + False, + [{"type": "E", "line": "5", "text": "Syntax error"}], + ) + + ddl_content = "define view z_test as select from sflight { invalid_field };" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".ddl", delete=False, encoding="utf-8" + ) as f: + f.write(ddl_content) + ddl_file = f.name + try: + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace( + cds_action="sync", + name="Z_TEST", + path=ddl_file, + config=None, + ), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("激活失败", printed) + finally: + os.unlink(ddl_file) + + def test_cds_unknown_action(self): + from sapcli.commands.cds import cmd_cds + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_cds( + argparse.Namespace(cds_action=None, name="Z_TEST", path=".", config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport list +# ═══════════════════════════════════════════════════════════════ + +class TestTransportList(unittest.TestCase): + """transport list 成功。""" + + def test_transport_list_with_requests(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "Dev request 1", "owner": "USER1"}, + {"number": "DEVK002", "description": "Dev request 2", "owner": "USER2"}, + ] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("DEVK001", printed) + + def test_transport_list_empty(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未找到", printed) + + def test_transport_list_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.list_transport_requests.side_effect = Exception("Connection failed") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="list", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport info +# ═══════════════════════════════════════════════════════════════ + +class TestTransportInfo(unittest.TestCase): + """transport info 成功。""" + + def test_transport_info_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_info.return_value = { + "number": "DEVK001", + "description": "Transport for feature X", + "status": "D", + "owner": "TESTUSER", + } + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="info", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("feature X", printed) + self.assertIn("TESTUSER", printed) + client.transport_info.assert_called_once_with("DEVK001") + + def test_transport_info_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_info.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="info", corr_nr="DEVK999", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport release +# ═══════════════════════════════════════════════════════════════ + +class TestTransportRelease(unittest.TestCase): + """transport release 成功。""" + + def test_transport_release_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = True + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("成功释放", printed) + + def test_transport_release_cancelled(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = True + + with patch("builtins.input", return_value="no"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("取消", printed) + client.transport_release.assert_not_called() + + def test_transport_release_failure(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.return_value = False + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_release_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_release.side_effect = Exception("Timeout") + + with patch("builtins.input", return_value="yes"): + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="release", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# transport.py — transport objects +# ═══════════════════════════════════════════════════════════════ + +class TestTransportObjects(unittest.TestCase): + """transport objects 成功。""" + + def test_transport_objects_with_results(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.return_value = [ + {"name": "ZTEST_REPORT", "type": "PROG/P"}, + {"name": "ZCL_MY_CLASS", "type": "CLAS/OC"}, + ] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("ZTEST_REPORT", printed) + + def test_transport_objects_empty(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无对象", printed) + + def test_transport_objects_api_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.transport_objects.side_effect = Exception("Server error") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace( + transport_action="objects", corr_nr="DEVK001", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_unknown_action(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action=None, config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package create +# ═══════════════════════════════════════════════════════════════ + +class TestTransportCreate(unittest.TestCase): + """transport create — 新建传输请求。 + + 历史背景:该子命令曾只存在于分发副本(源码 v2.1 重构时未纳入), + 刷新分发仓前先回迁到源码,避免同步时功能静默消失。 + """ + + def test_transport_create_success(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.return_value = "DEVK900123" + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="CEM BIP 传输请求", config=None), + client, + ) + + client.create_transport_request.assert_called_once_with("CEM BIP 传输请求") + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("DEVK900123", printed) + + def test_transport_create_client_error(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.side_effect = Exception("HTTP 403") + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="X", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_create_empty_number(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + client.create_transport_request.return_value = "" + + with patch("builtins.print") as mock_print: + cmd_transport( + argparse.Namespace(transport_action="create", + description="X", config=None), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_transport_usage_lists_create(self): + from sapcli.commands.transport import cmd_transport + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_transport(argparse.Namespace(transport_action=None, config=None), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("create", printed) + + +class TestPackageCreate(unittest.TestCase): + """package create 成功。""" + + def test_package_create_success(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.return_value = True + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_TEST_PKG", + description="Test Package", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("创建成功", printed) + client.create_package.assert_called_once_with("Z_TEST_PKG", "Test Package", None) + + def test_package_create_failure(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.return_value = False + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_DUP_PKG", + description="Duplicate", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_create_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.create_package.side_effect = Exception("Auth failed") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="create", + name="Z_ERR_PKG", + description="Error Pkg", + superpackage=None, + config=None, + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_create_with_superpackage(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_SUB_PKG", + description="Sub Package", + superpackage="Z_PARENT", + config=None, + ), + client, + ) + client.create_package.assert_called_once_with("Z_SUB_PKG", "Sub Package", "Z_PARENT") + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package info +# ═══════════════════════════════════════════════════════════════ + +class TestPackageInfo(unittest.TestCase): + """package info 成功。""" + + def test_package_info_success(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.return_value = { + "name": "Z_TEST", + "description": "Test Package", + "owner": "TESTUSER", + "superpackage": "Z_PARENT", + } + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_TEST", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("Test Package", printed) + self.assertIn("Z_PARENT", printed) + client.get_package_info.assert_called_once_with("Z_TEST") + + def test_package_info_no_superpackage(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.return_value = { + "name": "Z_TOP", + "description": "Top level", + "owner": "ADMIN", + "superpackage": "", + } + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_TOP", config=None + ), + client, + ) + + # info.get('superpackage', '(无)') returns '' when key exists but is empty + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + client.get_package_info.assert_called_once() + + def test_package_info_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.get_package_info.side_effect = Exception("Not found") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="info", name="Z_MISSING", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + +# ═══════════════════════════════════════════════════════════════ +# package_cmd.py — package list +# ═══════════════════════════════════════════════════════════════ + +class TestPackageList(unittest.TestCase): + """package list 成功。""" + + def test_package_list_with_objects(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.return_value = [ + {"name": "Z_REPORT1", "type": "PROG/P", "description": "Report 1"}, + {"name": "Z_CLASS1", "type": "CLAS/OC", "description": "Class 1"}, + ] + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_TEST_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("2", printed) + self.assertIn("Z_REPORT1", printed) + + def test_package_list_empty(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_EMPTY_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("没有找到", printed) + + def test_package_list_api_error(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + client.list_objects.side_effect = Exception("Timeout") + + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace( + package_action="list", name="Z_ERR_PKG", config=None + ), + client, + ) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("失败", printed) + + def test_package_unknown_action(self): + from sapcli.commands.package_cmd import cmd_package + + client = _make_client() + with patch("builtins.print") as mock_print: + cmd_package( + argparse.Namespace(package_action=None, name="Z_TEST", config=None), + client, + ) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("用法", printed) + + +# ═══════════════════════════════════════════════════════════════ +# upload.py — cmd_upload(仅锁定→写入→解锁,不检查不激活) +# ═══════════════════════════════════════════════════════════════ + +class TestUploadCommand(unittest.TestCase): + """cmd_upload 仅执行锁定→写入→解锁,绝不触发语法检查和激活。""" + + def _write_tmp_source(self, source="REPORT ztest.\nWRITE: / 'hello'.\n"): + """写入临时 .abap 源码文件,返回路径。""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) + f.write(source) + f.close() + return f.name + + def test_upload_success(self): + """成功上传:lock→set_source→unlock 被调用,syntax_check/activate 未被调用。""" + from sapcli.commands.upload import cmd_upload + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901362") + client.set_source.return_value = True + client.unlock.return_value = True + + tmpfile = self._write_tmp_source() + try: + cmd_upload( + _args(name="ZTEST", type="report", path=tmpfile, corr_nr=None), + client, + ) + finally: + os.unlink(tmpfile) + + client.object_exists.assert_called_once() + client.lock.assert_called_once() + client.set_source.assert_called_once() + client.unlock.assert_called_once() + # 关键:区分"真实现"和"假实现"——绝不能触发检查和激活 + client.syntax_check.assert_not_called() + client.activate.assert_not_called() + + def test_upload_nonexistent_object(self): + """对象不存在时抛 ObjectNotFoundError(不自动创建,区别于 sync)。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + tmpfile = self._write_tmp_source() + try: + with self.assertRaises(ObjectNotFoundError) as ctx: + cmd_upload( + _args(name="ZMISSING", type="report", path=tmpfile, corr_nr=None), + client, + ) + self.assertIn("ZMISSING", str(ctx.exception)) + finally: + os.unlink(tmpfile) + + # 对象不存在时不该尝试锁定/写入 + client.lock.assert_not_called() + client.set_source.assert_not_called() + + def test_upload_type_without_source(self): + """functiongroup 无源码类型抛 InvalidNameError。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import InvalidNameError + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_upload( + _args(name="ZTEST_FG", type="functiongroup", path=".", corr_nr=None), + client, + ) + + client.lock.assert_not_called() + client.set_source.assert_not_called() + + def test_upload_with_corr_nr(self): + """通过 --corr_nr 指定传输请求号,lock 使用该号。""" + from sapcli.commands.upload import cmd_upload + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901362") + client.set_source.return_value = True + client.unlock.return_value = True + + tmpfile = self._write_tmp_source() + try: + cmd_upload( + _args(name="ZTEST", type="class", path=tmpfile, corr_nr="DEVK901362"), + client, + ) + finally: + os.unlink(tmpfile) + + # corr_nr 可作为位置参数或关键字参数传入 + call_args = client.lock.call_args + passed_corr = ( + call_args[1].get("corr_nr") + if "corr_nr" in call_args[1] + else call_args[0][-1] + ) + self.assertEqual(passed_corr, "DEVK901362") + client.set_source.assert_called_once() + + def test_upload_file_not_found(self): + """本地文件不存在时抛 ConfigError。""" + from sapcli.commands.upload import cmd_upload + from sapcli.exceptions import ConfigError + + client = _make_client() + with self.assertRaises(ConfigError): + cmd_upload( + _args( + name="ZTEST", type="report", + path="/nonexistent/no_such_file.abap", corr_nr=None, + ), + client, + ) + + client.lock.assert_not_called() + client.set_source.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# syntax_check.py — cmd_syntax_check(远程对象语法检查,不上传不激活) +# ═══════════════════════════════════════════════════════════════ + +class TestSyntaxCheckCommand(unittest.TestCase): + """cmd_syntax_check 检查远程对象语法,不上传代码、不激活。""" + + def test_syntax_check_passed(self): + """语法检查通过,输出含"通过"。""" + from sapcli.commands.syntax_check import cmd_syntax_check + + client = _make_client() + client.object_exists.return_value = True + client.syntax_check.return_value = (True, []) + + with patch("builtins.print") as mock_print: + cmd_syntax_check(_args(name="ZTEST", type="class"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("通过", printed) + client.syntax_check.assert_called_once() + # 区分"真实现"和"假实现":绝不锁定/写入/激活 + client.lock.assert_not_called() + client.set_source.assert_not_called() + client.activate.assert_not_called() + + def test_syntax_check_failed(self): + """语法检查失败,输出含错误行号和描述。""" + from sapcli.commands.syntax_check import cmd_syntax_check + + client = _make_client() + client.object_exists.return_value = True + client.syntax_check.return_value = ( + False, + [{"type": "E", "line": "42", "text": "Field UNKNOWN not found", "href": ""}], + ) + + with patch("builtins.print") as mock_print: + cmd_syntax_check(_args(name="ZTEST", type="report"), client) + + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("42", printed) + self.assertIn("UNKNOWN", printed) + + def test_syntax_check_nonexistent(self): + """对象不存在时抛 ObjectNotFoundError。""" + from sapcli.commands.syntax_check import cmd_syntax_check + from sapcli.exceptions import ObjectNotFoundError + + client = _make_client() + client.object_exists.return_value = False + + with self.assertRaises(ObjectNotFoundError): + cmd_syntax_check(_args(name="ZMISSING", type="report"), client) + + client.syntax_check.assert_not_called() + + def test_syntax_check_type_without_source(self): + """无源码类型抛 InvalidNameError。""" + from sapcli.commands.syntax_check import cmd_syntax_check + from sapcli.exceptions import InvalidNameError + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_syntax_check(_args(name="ZTEST_FG", type="functiongroup"), client) + + client.syntax_check.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════ +# ddl_query.py — cmd_show_table / cmd_read_table +# ═══════════════════════════════════════════════════════════════ + +class TestShowTableCommand(unittest.TestCase): + """cmd_show_table 查询 DDIC 表字段结构。""" + + def test_show_table_success(self): + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [ + {"name": "MANDT", "type": "CLNT", "length": "3", "key_attribute": "X", "description": "Client"}, + {"name": "OBJ_ID", "type": "CHAR", "length": "20", "key_attribute": "X", "description": "Object ID"}, + ] + + with patch("builtins.print") as mock_print: + cmd_show_table(_args(name="ZMY_TABLE"), client) + + # 区分真/假实现:get_table_fields 被调用一次,且参数是大写表名 + client.get_table_fields.assert_called_once_with("ZMY_TABLE") + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("ZMY_TABLE", printed) + self.assertIn("OBJ_ID", printed) + + def test_show_table_no_fields(self): + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [] + + with patch("builtins.print") as mock_print: + cmd_show_table(_args(name="ZEMPTY"), client) + + # 空字段列表 → 打印"无字段信息"并返回 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无字段信息", printed) + client.get_table_fields.assert_called_once_with("ZEMPTY") + + def test_show_table_uppercase(self): + """传入小写表名,client.get_table_fields 收到的应是大写。""" + from sapcli.commands.ddl_query import cmd_show_table + + client = _make_client() + client.get_table_fields.return_value = [ + {"name": "F1", "type": "CHAR", "length": "10", "key_attribute": "", "description": "F"}, + ] + + with patch("builtins.print"): + cmd_show_table(_args(name="zmy_table"), client) + + # 大小写归一化在 cmd 内完成,client 收到 "ZMY_TABLE" + client.get_table_fields.assert_called_once_with("ZMY_TABLE") + + +class TestReadTableCommand(unittest.TestCase): + """cmd_read_table 通过 freestyle SQL 查询表数据。""" + + def test_read_table_basic(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT", "OBJ_ID"], + "rows": [["100", "OBJ1"], ["100", "OBJ2"]], + "total_rows": 2, + } + + with patch("builtins.print") as mock_print: + cmd_read_table(_args(name="ZMY_TABLE", max_rows=50), client) + + # 区分真/假实现:query_table_data 收到的 SQL 含大写表名和 UP TO N ROWS + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("ZMY_TABLE", sql_arg) + self.assertIn("UP TO 50 ROWS", sql_arg) + # max_rows 也作为关键字参数传给 query_table_data + self.assertEqual(client.query_table_data.call_args[1].get("max_rows"), 50) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("OBJ1", printed) + + def test_read_table_with_where(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT"], + "rows": [["100"]], + "total_rows": 1, + } + + with patch("builtins.print"): + cmd_read_table( + _args(name="ZMY_TABLE", where="FIELD1 = 'X'", max_rows=200), + client, + ) + + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("WHERE", sql_arg) + self.assertIn("FIELD1 = 'X'", sql_arg) + + def test_read_table_with_fields(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["F1", "F2"], + "rows": [["v1", "v2"]], + "total_rows": 1, + } + + with patch("builtins.print"): + cmd_read_table( + _args(name="ZMY_TABLE", fields="F1,F2", max_rows=200), + client, + ) + + # 指定字段 → SELECT F1,F2 而非 SELECT * + sql_arg = client.query_table_data.call_args[0][0] + self.assertIn("F1,F2", sql_arg) + self.assertTrue(sql_arg.startswith("SELECT F1,F2")) + + def test_read_table_empty_data(self): + from sapcli.commands.ddl_query import cmd_read_table + + client = _make_client() + client.query_table_data.return_value = { + "columns": ["MANDT"], + "rows": [], + "total_rows": 0, + } + + with patch("builtins.print") as mock_print: + cmd_read_table(_args(name="ZMY_TABLE", max_rows=200), client) + + # 空 rows → 打印"无数据"并返回 + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无数据", printed) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_crud_extra.py b/tests/unit/test_crud_extra.py new file mode 100644 index 0000000..d8133c8 --- /dev/null +++ b/tests/unit/test_crud_extra.py @@ -0,0 +1,1120 @@ +"""commands/crud.py 深入单元测试 — 补全 _sync_single / cmd_info / cmd_delete / +_select_transport_request / cmd_create / _create_ddic / manifest 更新等分支。 + +运行: python tests/unit/test_crud_extra.py +""" +from __future__ import annotations + +import argparse +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.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.exceptions import ( + CreateError, + DeleteError, + InvalidNameError, + LockError, + ObjectNotFoundError, + SapCliError, +) + + +# ── helpers ────────────────────────────────────────────────────── + +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(spec=ADTClient) + 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 + + +def _args(**kwargs): + defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None} + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def _write_source(content="REPORT ztest.\nWRITE: / 'hello'.\n"): + """写入临时 .abap 文件,返回路径。""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".abap", delete=False, encoding="utf-8" + ) + f.write(content) + f.close() + return f.name + + +def _info_xml(name="ZTEST", version="active", package=None): + head = ( + b'' + b'' + return ( + head + b'>' + b'' + b'' + ) + + +# ═══════════════════════════════════════════ +# cmd_download / cmd_sync — 无源码类型 +# ═══════════════════════════════════════════ + +class TestCmdDownloadNoSource(unittest.TestCase): + def test_download_functiongroup_raises(self): + from sapcli.commands.crud import cmd_download + + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_download( + _args(type="functiongroup", name="ZFG", path="."), client + ) + client.get_source.assert_not_called() + + +class TestCmdSyncNoSource(unittest.TestCase): + def test_sync_functiongroup_raises(self): + from sapcli.commands.crud import cmd_sync + + client = _make_client() + tmp = _write_source() + try: + with self.assertRaises(InvalidNameError): + cmd_sync( + _args(type="functiongroup", name="ZFG", path=tmp), client + ) + finally: + os.unlink(tmp) + + +# ═══════════════════════════════════════════ +# cmd_sync — project_path 探测 + 成功/失败清单更新 +# ═══════════════════════════════════════════ + +class TestCmdSyncProjectPathDetection(unittest.TestCase): + @patch("sapcli.commands.crud._sync_single") + def test_detects_project_path_from_manifest(self, mock_sync): + """文件同目录有 manifest.json → project_path 被设为该目录。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (True, None, "DEVK001") + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + + args = _args(type="report", name="ZTEST", path=filepath) + cmd_sync(args, client) + self.assertEqual(args.project_path, td) + + @patch("sapcli.commands.crud._sync_single") + def test_no_manifest_sets_project_path_none(self, mock_sync): + """文件同目录无 manifest.json → project_path = None。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (True, None, None) + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + args = _args(type="report", name="ZTEST", path=filepath) + cmd_sync(args, client) + self.assertIsNone(args.project_path) + + @patch("sapcli.commands.crud._sync_single") + def test_sync_failure_raises_and_updates_manifest(self, mock_sync): + """sync 失败时调用 _update_manifest_after_sync(failed=True) 并抛 SapCliError。""" + from sapcli.commands.crud import cmd_sync + + client = _make_client() + mock_sync.return_value = (False, "激活失败: 行3", "DEVK001") + + with tempfile.TemporaryDirectory() as td: + filepath = os.path.join(td, "ztest.abap") + with open(filepath, "w", encoding="utf-8") as f: + f.write("REPORT ztest.") + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + args = _args(type="report", name="ZTEST", path=filepath) + with self.assertRaises(SapCliError): + cmd_sync(args, client) + # 清单被写入失败标记 + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual( + data["objects"]["ZTEST"]["last_sync_result"], "failed" + ) + + +# ═══════════════════════════════════════════ +# _sync_single — 各分支 +# ═══════════════════════════════════════════ + +class TestSyncSingleBranches(unittest.TestCase): + """_sync_single 核心同步逻辑的各种分支。""" + + def test_no_source_type_returns_false(self): + from sapcli.commands.crud import _sync_single + + client = _make_client() + ok, err, corr = _sync_single("ZFG", "functiongroup", "/x.abap", client) + self.assertFalse(ok) + self.assertIn("没有源代码", err) + self.assertIsNone(corr) + + def test_file_not_found_returns_false(self): + from sapcli.commands.crud import _sync_single + + client = _make_client() + ok, err, corr = _sync_single( + "ZTEST", "report", "/nonexistent/no.abap", client + ) + self.assertFalse(ok) + self.assertIn("文件不存在", err) + self.assertIsNone(corr) + + def test_object_not_exists_auto_creates(self): + """对象不存在时自动 create_object。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = False + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + self.assertTrue(ok) + finally: + os.unlink(tmp) + client.create_object.assert_called_once() + + def test_create_object_failure_returns_false(self): + """自动创建对象失败 → 返回 False。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = False + client.create_object.side_effect = Exception("create boom") + + tmp = _write_source() + try: + ok, err, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("创建失败", err) + + def test_lock_with_corr_nr(self): + """指定 corr_nr 时用该号锁定。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK001") + # lock 第二位置参数为 corr_nr + self.assertEqual(client.lock.call_args[0][1], "DEVK001") + + def test_lock_without_corr_detects_transport(self): + """无 corr_nr 但 lock 返回检测到的传输请求号。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK901999") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK901999") + + def test_lock_without_corr_local_object(self): + """无 corr_nr 且 lock 未返回传输请求号 → 本地对象。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", None) + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertIsNone(corr) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_lock_error_then_select_transport(self, mock_select): + """lock 抛 LockError → 交互式选择传输请求后重试锁定。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = [LockError("need corr"), ("lh_1", "DEVK001")] + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertTrue(ok) + self.assertEqual(corr, "DEVK001") + mock_select.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value=None) + def test_lock_error_select_returns_none_fails(self, mock_select): + """lock 抛 LockError 且无法获取传输请求号 → 返回失败。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = [LockError("need corr")] + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client) + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("传输请求号", err) + + def test_lock_error_outer_caught(self): + """带 corr_nr 时 lock 直接抛 LockError(不走选择)→ 返回失败。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.side_effect = LockError("locked by other") + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("locked by other", err) + + def test_syntax_check_failure_returns_false(self): + """语法检查未通过 → 返回失败,列出错误。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = ( + False, + [ + {"type": "E", "line": "10", "text": "field missing"}, + {"type": "W", "line": "5", "text": "warning msg"}, + ], + ) + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("语法检查未通过", err) + self.assertIn("10", err) + self.assertEqual(corr, "DEVK001") + client.activate.assert_not_called() + + def test_syntax_check_exception_skips_to_activate(self): + """语法检查抛异常 → 跳过检查直接激活。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.side_effect = Exception("check svc down") + client.activate.return_value = (True, []) + + tmp = _write_source() + try: + ok, _, _ = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertTrue(ok) + client.activate.assert_called_once() + + def test_activation_failure_returns_false(self): + """激活失败 → 返回失败并列出错误。""" + from sapcli.commands.crud import _sync_single + + client = _make_client() + client.object_exists.return_value = True + client.lock.return_value = ("lh_1", "DEVK001") + client.set_source.return_value = True + client.unlock.return_value = True + client.syntax_check.return_value = (True, []) + client.activate.return_value = ( + False, + [ + {"type": "E", "line": "3", "text": "act error"}, + {"type": "W", "line": "1", "text": "act warn"}, + ], + ) + + tmp = _write_source() + try: + ok, err, corr = _sync_single("ZTEST", "report", tmp, client, corr_nr="DEVK001") + finally: + os.unlink(tmp) + self.assertFalse(ok) + self.assertIn("激活失败", err) + self.assertIn("3", err) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_sync +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterSync(unittest.TestCase): + def _manifest_with_entry(self, td, name="ZTEST"): + path = os.path.join(td, "manifest.json") + with open(path, "w", encoding="utf-8") as f: + json.dump( + { + "version": 1, + "objects": { + name: { + "type": "report", "file": "reports/ztest.abap", + "system_status": "active", + } + }, + }, + f, + ) + return path + + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_sync + _update_manifest_after_sync( + argparse.Namespace(project_path=None), "ZTEST", "report", "/x.abap", None + ) + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None + ) + + def test_existing_entry_updated_success(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + mpath = self._manifest_with_entry(td) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", + os.path.join(td, "ztest.abap"), "DEVK001", + ) + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["corr_nr"], "DEVK001") + self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "success") + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") + + def test_existing_entry_failure_keeps_status(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + mpath = self._manifest_with_entry(td) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", + os.path.join(td, "ztest.abap"), "DEVK001", failed=True, + ) + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "failed") + # failed 时 system_status 不变(仍 active) + self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active") + + def test_new_entry_upserted(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZNEW", "report", + os.path.join(td, "reports", "znew.abap"), "DEVK001", + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertIn("ZNEW", data["objects"]) + self.assertEqual(data["objects"]["ZNEW"]["system_status"], "active") + + def test_new_entry_failure_marked_inactive(self): + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZNEW", "report", + os.path.join(td, "znew.abap"), None, failed=True, + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZNEW"]["system_status"], "inactive") + self.assertEqual(data["objects"]["ZNEW"]["last_sync_result"], "failed") + + def test_corrupt_manifest_exception_swallowed(self): + """Manifest.load 抛异常时被吞掉,不传播。""" + from sapcli.commands.crud import _update_manifest_after_sync + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("NOT VALID JSON") + # 不抛异常 + _update_manifest_after_sync( + argparse.Namespace(project_path=td), "ZTEST", "report", "/x.abap", None + ) + + +# ═══════════════════════════════════════════ +# cmd_info — 状态图标 +# ═══════════════════════════════════════════ + +class TestCmdInfoStatusIcons(unittest.TestCase): + def test_inactive_version_icon(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml(version="inactive")) + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("未激活", printed) + + def test_unknown_version_icon(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml(version="draft")) + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("draft", printed) + + +# ═══════════════════════════════════════════ +# cmd_info — 开发包字段 +# ═══════════════════════════════════════════ + +class TestCmdInfoPackage(unittest.TestCase): + def test_package_displayed(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp( + 200, content=_info_xml(package="ZMY_PKG") + ) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("开发包", printed) + self.assertIn("ZMY_PKG", printed) + + def test_package_missing_shows_unspecified(self): + from sapcli.commands.crud import cmd_info + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_info_xml()) + client.query_table_data.return_value = {"rows": []} + with patch("builtins.print") as mock_print: + cmd_info(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("(未指定)", printed) + + +# ═══════════════════════════════════════════ +# cmd_delete — 分支 +# ═══════════════════════════════════════════ + +class TestCmdDeleteBranches(unittest.TestCase): + def test_delete_object_not_found_raises(self): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = False + with self.assertRaises(ObjectNotFoundError): + cmd_delete(_args(), client) + + @patch("builtins.input", return_value="no") + def test_delete_cancelled_returns(self, mock_input): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + with patch("builtins.print") as mock_print: + cmd_delete(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("取消", printed) + client.delete_object.assert_not_called() + + @patch("builtins.input", return_value="yes") + def test_delete_no_corr_nr(self, mock_input): + """无传输请求号时仍尝试删除。""" + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + client.get_transport_request.return_value = None + client.delete_object.return_value = (True, "") + with patch("builtins.print") as mock_print: + cmd_delete(_args(), client) + printed = " ".join(str(c) for c, _ in mock_print.call_args_list) + self.assertIn("无传输号", printed) + client.delete_object.assert_called_once() + + @patch("builtins.input", return_value="yes") + def test_delete_failure_raises(self, mock_input): + from sapcli.commands.crud import cmd_delete + client = _make_client() + client.object_exists.return_value = True + client.get_transport_request.return_value = "DEVK001" + client.delete_object.return_value = (False, "locked") + with patch("builtins.print"): + with self.assertRaises(DeleteError) as ctx: + cmd_delete(_args(), client) + self.assertIn("locked", str(ctx.exception)) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_delete +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterDelete(unittest.TestCase): + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_delete + _update_manifest_after_delete(argparse.Namespace(project_path=None), "ZTEST") + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + + def test_removes_existing_entry(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + mpath = os.path.join(td, "manifest.json") + with open(mpath, "w", encoding="utf-8") as f: + json.dump( + {"version": 1, "objects": {"ZTEST": {"type": "report", "file": "x.abap"}}}, + f, + ) + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + with open(mpath, encoding="utf-8") as f: + data = json.load(f) + self.assertNotIn("ZTEST", data["objects"]) + + def test_remove_nonexistent_entry_no_save(self): + """移除不存在的条目时 remove 返回 False,不保存。""" + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + mpath = os.path.join(td, "manifest.json") + with open(mpath, "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + mtime_before = os.path.getmtime(mpath) + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZNONE") + # 不抛异常即可(remove 返回 False → 不 save) + + def test_corrupt_manifest_exception_swallowed(self): + from sapcli.commands.crud import _update_manifest_after_delete + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("BAD JSON") + _update_manifest_after_delete(argparse.Namespace(project_path=td), "ZTEST") + + +# ═══════════════════════════════════════════ +# _select_transport_request — 交互式 +# ═══════════════════════════════════════════ + +class TestSelectTransportRequest(unittest.TestCase): + def test_api_error_returns_none(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.side_effect = Exception("fail") + with patch("builtins.print"): + self.assertIsNone(_select_transport_request(client)) + + def test_choose_existing_by_index(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + {"number": "DEVK002", "description": "b", "owner": "u"}, + ] + with patch("builtins.input", return_value="1"): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_choose_zero_no_transport(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", return_value="0"): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_choose_new_request_success(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + {"number": "DEVK002", "description": "b", "owner": "u"}, + ] + # "3" = 新建(2 + 1),随后输入描述 + with patch("builtins.input", side_effect=["3", "new feature"]): + client.create_transport_request.return_value = "DEVK999" + result = _select_transport_request(client) + self.assertEqual(result, "DEVK999") + + def test_choose_new_request_create_returns_none(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + # "2" = 新建,描述,create 返回 None + with patch("builtins.input", side_effect=["2", "desc"]): + client.create_transport_request.return_value = None + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_choose_new_request_create_raises(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["2", "desc"]): + client.create_transport_request.side_effect = Exception("no perm") + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_invalid_input_then_valid(self): + """非数字输入后重新输入有效索引。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["abc", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_empty_input_then_valid(self): + """空输入(直接回车)时重新提示。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_new_request_empty_desc_then_valid(self): + """新建请求但描述为空 → 提示后重新选择,再选已有请求。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + # "2"=新建, ""=空描述(continue), "1"=选 DEVK001 + with patch("builtins.input", side_effect=["2", "", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_out_of_range_then_valid(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [ + {"number": "DEVK001", "description": "a", "owner": "u"}, + ] + with patch("builtins.input", side_effect=["99", "1"]): + result = _select_transport_request(client) + self.assertEqual(result, "DEVK001") + + def test_empty_list_create_new(self): + """无可修改请求时,选择新建。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", "new desc"]): + client.create_transport_request.return_value = "DEVK888" + result = _select_transport_request(client) + self.assertEqual(result, "DEVK888") + + def test_empty_list_decline(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", return_value="n"): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_empty_list_empty_description(self): + """空列表 + 选择新建但描述为空 → 无传输号创建。""" + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", ""]): + result = _select_transport_request(client) + self.assertIsNone(result) + + def test_empty_list_create_raises(self): + from sapcli.commands.crud import _select_transport_request + client = _make_client() + client.list_transport_requests.return_value = [] + with patch("builtins.input", side_effect=["y", "desc"]): + client.create_transport_request.side_effect = Exception("err") + result = _select_transport_request(client) + self.assertIsNone(result) + + +# ═══════════════════════════════════════════ +# cmd_create — function / functiongroup / 异常 +# ═══════════════════════════════════════════ + +class TestCmdCreateBranches(unittest.TestCase): + def test_create_function_without_slash_raises(self): + from sapcli.commands.crud import cmd_create + client = _make_client() + with self.assertRaises(InvalidNameError): + cmd_create( + _args(type="function", name="Z_NO_SLASH", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + def test_create_object_already_exists_raises(self): + """非 functiongroup 类型且对象已存在 → ObjectAlreadyExistsError。""" + from sapcli.commands.crud import cmd_create + from sapcli.exceptions import ObjectAlreadyExistsError + client = _make_client() + client.object_exists.return_value = True + with self.assertRaises(ObjectAlreadyExistsError): + cmd_create( + _args(type="class", name="ZCL_EXISTS", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + @patch("sapcli.commands.crud.Manifest") + def test_create_corr_nr_specified_skips_select(self, MockManifest): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.return_value = ("/uri/z", "/uri/z/source") + cmd_create( + _args(type="report", name="ZTEST", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + 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_function_creates_group(self, MockManifest, mock_transport): + """function 类型且函数组不存在 → 自动创建函数组。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = False + client.create_function_group.return_value = "/uri/group" + client.create_object.return_value = ("/uri/func", "/uri/func/source") + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + client.create_function_group.assert_called_once() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + @patch("sapcli.commands.crud.Manifest") + def test_create_function_group_exists(self, MockManifest, mock_transport): + """function 类型且函数组已存在 → 跳过创建。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = True + client.create_object.return_value = ("/uri/func", "/uri/func/source") + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + client.create_function_group.assert_not_called() + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_function_group_create_raises(self, mock_transport): + """function 函数组创建失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.function_group_exists.return_value = False + client.create_function_group.side_effect = Exception("group fail") + with self.assertRaises(CreateError): + cmd_create( + _args(type="function", name="ZGRP/Z_FUNC", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + + @patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001") + def test_create_functiongroup_create_raises(self, mock_transport): + """functiongroup 创建失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.function_group_exists.return_value = False + client.create_function_group.side_effect = Exception("boom") + with self.assertRaises(CreateError): + cmd_create( + _args(type="functiongroup", name="ZFG", source=None, + description="d", package="$TMP", corr_nr=None), + client, + ) + + @patch("sapcli.commands.crud.Manifest") + def test_create_object_raises(self, MockManifest): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_object.side_effect = Exception("create boom") + with self.assertRaises(CreateError): + cmd_create( + _args(type="report", name="ZTEST", source=None, + description="d", package="$TMP", corr_nr="DEVK001"), + client, + ) + + +# ═══════════════════════════════════════════ +# _update_manifest_after_create +# ═══════════════════════════════════════════ + +class TestUpdateManifestAfterCreate(unittest.TestCase): + def test_no_project_path_returns(self): + from sapcli.commands.crud import _update_manifest_after_create + _update_manifest_after_create( + argparse.Namespace(project_path=None), "ZTEST", "report", "DEVK001" + ) + + def test_no_manifest_file_returns(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" + ) + + def test_report_gets_reports_dir(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", "DEVK001" + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["objects"]["ZTEST"]["file"], "reports/ztest.abap") + + def test_function_gets_functions_dir(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + json.dump({"version": 1, "objects": {}}, f) + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZGRP/Z_FUNC", "function", None + ) + with open(os.path.join(td, "manifest.json"), encoding="utf-8") as f: + data = json.load(f) + self.assertEqual( + data["objects"]["ZGRP/Z_FUNC"]["file"], "functions/zgrp/z_func.abap" + ) + + def test_corrupt_manifest_exception_swallowed(self): + from sapcli.commands.crud import _update_manifest_after_create + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "manifest.json"), "w", encoding="utf-8") as f: + f.write("BAD") + _update_manifest_after_create( + argparse.Namespace(project_path=td), "ZTEST", "report", None + ) + + +# ═══════════════════════════════════════════ +# _create_ddic — 各类型 + 默认模板 + 异常 +# ═══════════════════════════════════════════ + +class TestCreateDdic(unittest.TestCase): + def _run_create(self, obj_type, name, definition_file=None): + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.return_value = ("/uri", "/uri/source") + with patch("sapcli.commands.crud.Manifest"): + cmd_create( + _args(type=obj_type, name=name, source=None, + definition=definition_file, description="d", + package="$TMP", corr_nr="DEVK001"), + client, + ) + return client + + def _write_def(self, payload): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) + json.dump(payload, f) + f.close() + return f.name + + def test_structure_with_definition(self): + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "fields": [{"name": "F1", "type": "char10"}], + "enhancement_category": "#NOT_CLASSIFIED", + }) + try: + client = self._run_create("structure", "ZSTRUCT", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + def test_tabletype_with_definition(self): + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "line_type": "ZSTRUCT", + "key_type": "#USER_DEFINED", + "access_mode": "#STANDARD", + }) + try: + client = self._run_create("tabletype", "ZTT", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + def test_domain_default_template(self): + """domain 无 definition → 默认 DomainDefinition 模板。""" + client = self._run_create("domain", "ZDOM", None) + client.create_ddic_object.assert_called_once() + body = client.create_ddic_object.call_args[0][2] + # 默认模板应生成非空 XML body + self.assertIsInstance(body, str) + self.assertTrue(len(body) > 0) + + def test_table_default_template(self): + """table 无 definition → 默认 TableDefinition 模板。""" + client = self._run_create("table", "ZTAB", None) + client.create_ddic_object.assert_called_once() + + def test_dataelement_default_template(self): + """dataelement 无 definition → 默认模板。""" + client = self._run_create("dataelement", "ZDE", None) + client.create_ddic_object.assert_called_once() + + def test_structure_default_template(self): + """structure 无 definition → 默认 StructureDefinition 模板。""" + client = self._run_create("structure", "ZSTRUCT", None) + client.create_ddic_object.assert_called_once() + + def test_create_ddic_object_raises(self): + """create_ddic_object 失败 → CreateError。""" + from sapcli.commands.crud import cmd_create + client = _make_client() + client.object_exists.return_value = False + client.create_ddic_object.side_effect = Exception("ddic fail") + with patch("sapcli.commands.crud.Manifest"): + with self.assertRaises(CreateError): + cmd_create( + _args(type="domain", name="ZDOM", source=None, + definition=None, description="d", + package="$TMP", corr_nr="DEVK001"), + client, + ) + + def test_domain_with_definition_fix_values(self): + """domain definition 含 fix_values → 仍走 to_xml。""" + from sapcli.commands.crud import cmd_create + deffile = self._write_def({ + "datatype": "CHAR", "length": 1, + "fix_values": [{"low": "A", "text": "Active"}], + }) + try: + client = self._run_create("domain", "ZDOM", deffile) + client.create_ddic_object.assert_called_once() + finally: + os.unlink(deffile) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_ddic.py b/tests/unit/test_ddic.py new file mode 100644 index 0000000..729afab --- /dev/null +++ b/tests/unit/test_ddic.py @@ -0,0 +1,1076 @@ +"""client/_ddic.py 单元测试 — DdicMixin 的 DDIC/CRUD/查询/版本/增强方法。 + +与 test_client.py 互补:后者覆盖 _ddic 的部分公共方法,本文件聚焦 +未覆盖的方法与分支(function 创建、各 _build_create_body 类型、DDIC 写入、 +表查询、ABAP Unit、版本历史、增强实现、XML namespace helpers 等)。 + +运行: python -m pytest tests/unit/test_ddic.py -v +""" +from __future__ import annotations + +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets")) + +from sapcli.client import ADTClient +from sapcli.client._ddic import _local, _attr_local, _find_local +from sapcli.exceptions import CreateError, DeleteError +from sapcli.types import parse_object_name + +# ADT XML 命名空间 +ADTCORE_NS = "http://www.sap.com/adt/core" +LOCK_NS = "http://www.sap.com/adt/lock" +DP_NS = "http://www.sap.com/adt/dataPreview" + + +def _make_client() -> ADTClient: + """创建 mock 好的 ADTClient(绕过 __init__,手动注入依赖)。""" + client = ADTClient.__new__(ADTClient) + client.host = "https://sap.example.com" + client.sap_client = "100" + client.user = "TESTUSER" + client.password = "TESTPASS" + client.csrf_token = "test-csrf-token" + client.session = MagicMock() + client._stateful = False + return client + + +def _mock_resp(status_code=200, text="", content=b"", headers=None): + """快速创建 mock response。""" + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.content = content + resp.headers = headers or {} + resp.raise_for_status = MagicMock() + return resp + + +def _lock_resp(handle="lh", corr=""): + """构造 lock POST 成功响应。""" + return _mock_resp(200, content=( + f'{handle}' + f'{corr}' + ).encode()) + + +# ═══════════════════════════════════════════ +# 模块级 XML namespace helpers +# ═══════════════════════════════════════════ + +class TestLocalHelpers(unittest.TestCase): + """_local / _attr_local / _find_local — 本地名匹配,忽略命名空间。""" + + def test_local_with_namespace(self): + self.assertEqual(_local("{http://www.sap.com/adt/core}name"), "name") + + def test_local_without_namespace(self): + self.assertEqual(_local("name"), "name") + + def test_local_empty(self): + self.assertEqual(_local(""), "") + + def test_attr_local_found(self): + el = ET.fromstring(f'') + self.assertEqual(_attr_local(el, "name"), "ZTEST") + + def test_attr_local_plain_name(self): + """无命名空间前缀的属性也能匹配。""" + el = ET.fromstring("") + self.assertEqual(_attr_local(el, "name"), "ZTEST") + + def test_attr_local_missing_returns_empty(self): + el = ET.fromstring("") + self.assertEqual(_attr_local(el, "name"), "") + + def test_find_local_found(self): + root = ET.fromstring( + f'' + ) + summary = _find_local(root, "summary") + self.assertIsNotNone(summary) + self.assertEqual(summary.attrib.get("tests"), "3") + + def test_find_local_missing_returns_none(self): + root = ET.fromstring("") + self.assertIsNone(_find_local(root, "summary")) + + +# ═══════════════════════════════════════════ +# delete_object — corr_nr 分支 +# ═══════════════════════════════════════════ + +class TestDeleteObjectCorrNr(unittest.TestCase): + + def test_with_corr_nr(self): + """带 corr_nr 时 DELETE 请求参数应包含 corrNr。""" + client = _make_client() + client.session.post.return_value = _lock_resp(handle="lh_del") + client.session.delete.return_value = _mock_resp(204) + ok, msg = client.delete_object("/uri", corr_nr="DEVK001") + self.assertTrue(ok) + # 验证 DELETE 调用带上了 lockHandle + corrNr + _, kwargs = client.session.delete.call_args + self.assertEqual(kwargs["params"]["lockHandle"], "lh_del") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# parse_object_name — tabletype 存在性检查端点(缺陷2) +# ═══════════════════════════════════════════ + +class TestTableTypeExistsUri(unittest.TestCase): + """tabletype 的存在性检查必须用 DDIC 端点,而非 VIT 端点。 + + VIT 端点 /vit/wb/object_type/ttypda/object_name/{name} 对任意名称都返回 200, + 导致 create 永远走到"对象已存在"分支。obj_uri 仍保留 VIT 供 info 查询完整元数据。 + """ + + def test_tabletype_exists_uri_uses_ddic_endpoint(self): + parsed = parse_object_name("Z_MY_TT", "tabletype") + self.assertEqual( + parsed.exists_uri, + "/sap/bc/adt/ddic/tabletypes/z_my_tt", + ) + + def test_tabletype_obj_uri_still_vit_for_info(self): + """obj_uri 仍是 VIT 端点(info 命令依赖它返回完整元数据)。""" + parsed = parse_object_name("Z_MY_TT", "tabletype") + self.assertIn("/vit/wb/object_type/ttypda/object_name/Z_MY_TT", parsed.obj_uri) + # exists_uri 必须不同于 VIT obj_uri,否则存在性检查失效 + self.assertNotEqual(parsed.exists_uri, parsed.obj_uri) + + def test_non_tabletype_exists_uri_equals_obj_uri(self): + """非 tabletype 类型 exists_uri 回退为 obj_uri(行为不变)。""" + parsed = parse_object_name("ZTEST_DOM", "domain") + self.assertEqual(parsed.exists_uri, parsed.obj_uri) + + +# ═══════════════════════════════════════════ +# create_object — function 分支 + corr_nr +# ═══════════════════════════════════════════ + +class TestCreateObjectFunction(unittest.TestCase): + + def test_function_with_group(self): + """function 类型 'GROUP/FM' 格式正确解析。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + obj_uri, src_uri = client.create_object("function", "ZGROUP/Z_FUNC", "Test fm") + # obj_uri 形如 /sap/bc/adt/functions/groups/zgroup/fmodules/z_func + self.assertIn("zgroup", obj_uri) + self.assertIn("z_func", obj_uri) + self.assertIsNotNone(src_uri) + + def test_function_missing_slash_raises(self): + """function 类型缺少 '/' 分隔符 → CreateError。""" + client = _make_client() + with self.assertRaises(CreateError): + client.create_object("function", "NOSLASH", "Test") + + def test_with_corr_nr_passes_param(self): + """带 corr_nr 时 POST 请求参数应包含 corrNr(覆盖 line 81)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + client.create_object("report", "ZTEST", "Test", corr_nr="DEVK001") + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# _build_create_body — 未覆盖的 6 种类型 +# ═══════════════════════════════════════════ + +class TestBuildCreateBodyRemainingTypes(unittest.TestCase): + + def _check(self, obj_type, name, expected_marker): + body = _make_client()._build_create_body(obj_type, name, f"Test {obj_type}") + self.assertIn(name, body) + self.assertIn(expected_marker, body) + + def test_function(self): + self._check("function", "Z_FUNC", "abapFunctionModule") + + def test_include(self): + self._check("include", "ZINCL", "PROG/I") + + def test_include_uses_includes_namespace(self): + """include 使用 programs/includes 命名空间 + abapInclude 元素(缺陷1)。 + + 参考 abap-adt-api objectcreator.ts: + rootName="include:abapInclude", + nameSpace="http://www.sap.com/adt/programs/includes" + 不应复用 report 的 ,否则 POST /programs/includes 返回 400。 + """ + body = _make_client()._build_create_body("include", "ZINCL", "Test include") + self.assertIn("include:abapInclude", body) + self.assertIn("http://www.sap.com/adt/programs/includes", body) + self.assertNotIn("abapProgram", body) + + def test_messageclass(self): + self._check("messageclass", "ZMSG", "t100MessageClass") + + def test_view(self): + self._check("view", "ZVIEW", "dictionary/views") + + def test_searchhelp(self): + self._check("searchhelp", "ZSHLP", "searchHelp") + + def test_lockobject(self): + self._check("lockobject", "ZLOCK", "lockObject") + + def test_description_defaults_to_name(self): + """未提供 description 时用 name 作为描述。""" + body = _make_client()._build_create_body("include", "ZINCL", None) + self.assertIn('description="ZINCL"', body) + + +# ═══════════════════════════════════════════ +# create_ddic_object — corr_nr + 激活失败 +# ═══════════════════════════════════════════ + +_DDIC_DOMAIN_XML = ( + '' + '' + '' + '' +) + + +class TestCreateDDICObjectExtra(unittest.TestCase): + + def test_with_corr_nr(self): + """带 corr_nr 走非 table 分支,POST 参数含 corrNr。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=(True, [])) as mock_act: + obj_uri, src_uri = client.create_ddic_object( + "domain", "ZTEST_DOM", _DDIC_DOMAIN_XML, corr_nr="DEVK001" + ) + # POST 创建带 corrNr + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + # activate 收到 corr_nr + mock_act.assert_called_once() + self.assertIn("ztest_dom", obj_uri) + + def test_activation_failure_with_errors_raises(self): + """激活失败且含 type=E 消息 → CreateError。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=( + False, [{"type": "E", "text": "domain error"}] + )): + with self.assertRaises(CreateError) as ctx: + client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("domain error", str(ctx.exception)) + + def test_activation_failure_no_errors_returns(self): + """激活失败但无 type=E 消息 → 不抛异常,正常返回。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "activate", return_value=( + False, [{"type": "W", "text": "warning only"}] + )): + obj_uri, _ = client.create_ddic_object("domain", "ZTEST_DOM", _DDIC_DOMAIN_XML) + self.assertIn("ztest_dom", obj_uri) + + def test_structure_posts_entity_then_puts_ddl(self): + """structure 先 POST 创建对象实体,再 PUT 写入 DDL(缺陷3)。 + + 之前 create_ddic_object 对 table/structure 直接 _put_ddl_source, + PUT 到尚未创建的对象 /source/main 返回 405。修复后先 POST 集合端点创建实体。 + """ + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + obj_uri, _ = client.create_ddic_object("structure", "ZTEST_STR", "") + # 先 POST 创建对象实体 + client.session.post.assert_called_once() + post_url = client.session.post.call_args[0][0] + self.assertIn("/ddic/structures", post_url) + # 再 PUT 写入 DDL 定义 + mock_ddl.assert_called_once() + self.assertIn("ztest_str", obj_uri) + + def test_table_posts_entity_then_puts_ddl(self): + """table 同样先 POST 再 PUT。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + client.create_ddic_object("table", "ZTEST_TAB", "") + client.session.post.assert_called_once() + self.assertIn("/ddic/tables", client.session.post.call_args[0][0]) + mock_ddl.assert_called_once() + + def test_table_post_failure_raises_before_put(self): + """POST 实体创建失败 → CreateError,且不执行 PUT。""" + client = _make_client() + client.session.post.return_value = _mock_resp(405, text="Method Not Allowed") + with patch.object(client, "_put_ddl_source") as mock_ddl, \ + patch.object(client, "activate", return_value=(True, [])): + with self.assertRaises(CreateError): + client.create_ddic_object("table", "ZTEST_TAB", "") + mock_ddl.assert_not_called() + + +# ═══════════════════════════════════════════ +# _put_ddic_xml +# ═══════════════════════════════════════════ + +class TestPutDdicXml(unittest.TestCase): + + def test_success_with_lock_and_corr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue( + client._put_ddic_xml("/uri", "", lock_handle="lh", corr_nr="DEVK001") + ) + _, kwargs = client.session.put.call_args + self.assertEqual(kwargs["params"]["lockHandle"], "lh") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + self.assertEqual(kwargs["headers"]["Accept"], "*/*") + + def test_success_no_lock_no_corr(self): + client = _make_client() + client.session.put.return_value = _mock_resp(200) + self.assertTrue(client._put_ddic_xml("/uri", "")) + _, kwargs = client.session.put.call_args + # 无 lock_handle/corr_nr 时不带对应参数 + self.assertNotIn("lockHandle", kwargs["params"]) + self.assertNotIn("corrNr", kwargs["params"]) + + def test_failure_raises_create_error(self): + client = _make_client() + client.session.put.return_value = _mock_resp(400, text="bad xml") + with self.assertRaises(CreateError): + client._put_ddic_xml("/uri", "") + + +# ═══════════════════════════════════════════ +# _put_ddl_with_auto_lock +# ═══════════════════════════════════════════ + +class TestPutDdlWithAutoLock(unittest.TestCase): + + def test_lock_succeeds_unlock_called(self): + client = _make_client() + with patch.object(client, "lock", return_value=("lh", "DEVK001")) as mock_lock, \ + patch.object(client, "set_source", return_value=True) as mock_src, \ + patch.object(client, "unlock") as mock_unlock: + result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri", corr_nr="DEVK001") + self.assertTrue(result) + mock_lock.assert_called_once_with("/uri", "DEVK001") + mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001") + mock_unlock.assert_called_once_with("/uri", "lh") + + def test_lock_fails_no_unlock(self): + """lock 抛异常 → 用空锁句柄继续,且不调 unlock。""" + client = _make_client() + with patch.object(client, "lock", side_effect=Exception("lock boom")), \ + patch.object(client, "set_source", return_value=True) as mock_src, \ + patch.object(client, "unlock") as mock_unlock: + result = client._put_ddl_with_auto_lock("/src", "ddl", "/uri") + self.assertTrue(result) + # set_source 收到空 lock_handle + self.assertEqual(mock_src.call_args.args[2], "") + mock_unlock.assert_not_called() + + +# ═══════════════════════════════════════════ +# _put_ddl_source +# ═══════════════════════════════════════════ + +class TestPutDdlSource(unittest.TestCase): + + def test_delegates_to_set_source(self): + client = _make_client() + with patch.object(client, "set_source", return_value=True) as mock_src: + result = client._put_ddl_source("/src", "ddl", "lh", corr_nr="DEVK001") + self.assertTrue(result) + mock_src.assert_called_once_with("/src", "ddl", "lh", "DEVK001") + + +# ═══════════════════════════════════════════ +# get_object_status — 全分支 +# ═══════════════════════════════════════════ + +class TestGetObjectStatusExtra(unittest.TestCase): + + def test_unexpected_status_returns_not_exists(self): + """非 200/404 状态码 → not_exists(覆盖 warning 分支)。""" + client = _make_client() + client.session.get.return_value = _mock_resp(500) + result = client.get_object_status("/uri") + self.assertFalse(result["exists"]) + self.assertEqual(result["status"], "not_exists") + + def test_inactive_version(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertTrue(result["exists"]) + self.assertEqual(result["status"], "inactive") + + def test_custom_version_string(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertEqual(result["status"], "custom_v2") + + def test_unparseable_content_stays_active(self): + """XML 解析失败 → status 保持 active。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=b"<>") + with patch.object(client, "lock", return_value=("lh", "")), \ + patch.object(client, "unlock", return_value=True): + result = client.get_object_status("/uri") + self.assertEqual(result["status"], "active") + + def test_corr_nr_detected_via_lock_probe(self): + """lock 探测返回 corr_nr → 写入结果。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", return_value=("lh", "DEVK901999")), \ + patch.object(client, "unlock", return_value=True) as mock_unlock: + result = client.get_object_status("/uri") + self.assertEqual(result["corr_nr"], "DEVK901999") + mock_unlock.assert_called_once_with("/uri", "lh") + + def test_lock_probe_failure_keeps_corr_none(self): + """lock 探测异常 → corr_nr 保持 None,不抛出。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + with patch.object(client, "lock", side_effect=Exception("no perm")): + result = client.get_object_status("/uri") + self.assertIsNone(result["corr_nr"]) + self.assertTrue(result["exists"]) + + +# ═══════════════════════════════════════════ +# create_function_group — corr_nr +# ═══════════════════════════════════════════ + +class TestCreateFunctionGroupCorrNr(unittest.TestCase): + + def test_with_corr_nr(self): + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_function_group("ZGROUP", corr_nr="DEVK001")) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["groupname"], "ZGROUP") + self.assertEqual(kwargs["params"]["corrNr"], "DEVK001") + + +# ═══════════════════════════════════════════ +# create_package — superpackage +# ═══════════════════════════════════════════ + +class TestCreatePackageSuperpackage(unittest.TestCase): + + def test_with_superpackage(self): + """带 superpackage → body 含 packageRef(覆盖 line 604)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST", "desc", superpackage="Z_PARENT")) + _, kwargs = client.session.post.call_args + body = kwargs["data"].decode("utf-8") + self.assertIn("packageRef", body) + self.assertIn("Z_PARENT", body) + + def test_without_superpackage(self): + """无 superpackage → body 以自闭合结尾。""" + client = _make_client() + client.session.post.return_value = _mock_resp(201) + self.assertTrue(client.create_package("Z_TEST")) + _, kwargs = client.session.post.call_args + body = kwargs["data"].decode("utf-8") + self.assertNotIn("packageRef", body) + self.assertTrue(body.rstrip().endswith("/>")) + + +# ═══════════════════════════════════════════ +# get_package_info — packageRef +# ═══════════════════════════════════════════ + +class TestGetPackageInfoSuperpackage(unittest.TestCase): + + def test_with_package_ref(self): + """XML 含 adtcore:packageRef → 解析 superpackage(覆盖 line 650)。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + info = client.get_package_info("Z_TEST") + self.assertEqual(info["name"], "Z_TEST") + self.assertEqual(info["description"], "Test pkg") + self.assertEqual(info["owner"], "DEVUSER") + self.assertEqual(info["superpackage"], "Z_PARENT") + + def test_without_package_ref(self): + """XML 无 packageRef → superpackage 保持空字符串。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + ).encode()) + info = client.get_package_info("Z_TEST") + self.assertEqual(info["superpackage"], "") + + +# ═══════════════════════════════════════════ +# atc_check — variant +# ═══════════════════════════════════════════ + +class TestATCCheckVariant(unittest.TestCase): + + def test_with_variant(self): + """带 variant → POST 参数含 variant(覆盖 line 677)。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri", variant="MY_VARIANT") + self.assertTrue(ok) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["variant"], "MY_VARIANT") + self.assertEqual(kwargs["params"]["context"], "/uri") + + def test_findings_severity_one_treated_as_error(self): + """severity="1" 同样视为错误。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + f'' + ).encode()) + ok, findings = client.atc_check("ZTEST", "/uri") + self.assertFalse(ok) + self.assertEqual(len(findings), 1) + + +# ═══════════════════════════════════════════ +# get_table_fields +# ═══════════════════════════════════════════ + +_TABLE_FIELDS_XML = ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' +).encode() + + +class TestGetTableFields(unittest.TestCase): + + def test_parses_fields(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML) + fields = client.get_table_fields("ZTEST_TAB") + self.assertEqual(len(fields), 2) + self.assertEqual(fields[0]["name"], "MANDT") + self.assertEqual(fields[0]["type"], "CHAR") + self.assertEqual(fields[0]["length"], "3") + self.assertEqual(fields[0]["description"], "Client") + self.assertEqual(fields[0]["key_attribute"], "true") + # 未声明 keyAttribute 时默认 "false" + self.assertEqual(fields[1]["key_attribute"], "false") + + def test_uses_lowercase_table_name(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_TABLE_FIELDS_XML) + client.get_table_fields("ZTEST_TAB") + url = client.session.get.call_args.args[0] + self.assertIn("/ztest_tab/", url) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.get.return_value = mock + with self.assertRaises(Exception): + client.get_table_fields("ZTEST_TAB") + + +# ═══════════════════════════════════════════ +# query_table_data +# ═══════════════════════════════════════════ + +_FREESTYLE_XML = ( + f'' + f'' + f'' + f'' + f'100200' + f'' + f'' + f'' + f'A001A002' + f'' + f'2' + f'0.012' + f'' +).encode() + + +class TestQueryTableData(unittest.TestCase): + + def test_parses_and_transposes(self): + """列存储 → 行存储转置。""" + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML) + result = client.query_table_data("SELECT * FROM ztest", max_rows=50) + self.assertEqual(result["columns"], ["MANDT", "MATNR"]) + self.assertEqual(result["rows"], [["100", "A001"], ["200", "A002"]]) + self.assertEqual(result["total_rows"], 2) + self.assertEqual(result["execution_time"], "0.012") + + def test_max_rows_param_sent(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_FREESTYLE_XML) + client.query_table_data("SELECT * FROM ztest", max_rows=500) + _, kwargs = client.session.post.call_args + self.assertEqual(kwargs["params"]["rowNumber"], "500") + + def test_columns_without_dataset_yields_empty_values(self): + """某列无 dataSet → 该列补空字符串(覆盖 dataset is None 分支)。""" + xml = ( + f'' + f'' + f'' + f'' + f'100' + f'' + f'' + f'' + ).encode() + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=xml) + result = client.query_table_data("SELECT * FROM ztest") + self.assertEqual(result["rows"], [["100", ""]]) + + def test_no_columns_returns_empty(self): + """无任何 columns → 覆盖 max(..., default=0)。""" + xml = ( + f'' + f'' + f'0' + f'' + ).encode() + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=xml) + result = client.query_table_data("SELECT * FROM ztest") + self.assertEqual(result["columns"], []) + self.assertEqual(result["rows"], []) + self.assertEqual(result["total_rows"], 0) + + +# ═══════════════════════════════════════════ +# run_program +# ═══════════════════════════════════════════ + +class TestRunProgram(unittest.TestCase): + + def test_success_returns_text(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="PROGRAM OUTPUT") + self.assertEqual(client.run_program("ZSAPILOT_SETUP"), "PROGRAM OUTPUT") + + def test_url_uses_lowercase_name(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, text="out") + client.run_program("ZPROG") + url = client.session.post.call_args.args[0] + self.assertIn("/programrun/zprog", url) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.run_program("ZPROG") + + +# ═══════════════════════════════════════════ +# run_unit_test + _parse_aunit_result +# ═══════════════════════════════════════════ + +_AUNIT_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestRunUnitTest(unittest.TestCase): + + def test_success_returns_parsed(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_AUNIT_XML) + result = client.run_unit_test("/sap/bc/adt/oo/classes/zcl_test") + self.assertEqual(result["summary"]["tests"], "3") + self.assertEqual(result["summary"]["failures"], "1") + self.assertEqual(len(result["classes"]), 1) + # 验证 body 含 obj_uri + _, kwargs = client.session.post.call_args + self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8")) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.run_unit_test("/uri") + + +class TestParseAunitResult(unittest.TestCase): + + def test_full_parse(self): + result = _make_client()._parse_aunit_result(_AUNIT_XML) + self.assertEqual(result["summary"]["duration"], "450") + cls = result["classes"][0] + self.assertEqual(cls["name"], "ZCL_TEST") + methods = {m["name"]: m for m in cls["methods"]} + self.assertIn("test_foo", methods) + self.assertEqual(methods["test_foo"]["duration"], "0.45") + self.assertEqual(methods["test_foo"]["alert"], "assertion error") + self.assertEqual(methods["test_foo"]["line"], "42") + # test_bar 无 alert + self.assertEqual(methods["test_bar"]["alert"], "") + self.assertEqual(methods["test_bar"]["line"], "") + + def test_empty_result_defaults(self): + """无 summary/testClass → 默认值。""" + xml = ( + '' + '' + ).encode() + result = _make_client()._parse_aunit_result(xml) + self.assertEqual(result["summary"]["tests"], "0") + self.assertEqual(result["classes"], []) + + def test_multiple_alerts_uses_first_title(self): + """多个 alert → alert 取首个 title。""" + xml = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_aunit_result(xml) + m = result["classes"][0]["methods"][0] + self.assertEqual(m["alert"], "first") + self.assertEqual(m["line"], "9") + + +# ═══════════════════════════════════════════ +# get_object_versions + _get_versions_link + _parse_versions +# ═══════════════════════════════════════════ + +_VERSIONS_LINK_XML = ( + f'' + f'' + f'' + f'' +).encode() + +_VERSIONS_FEED_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestGetObjectVersions(unittest.TestCase): + + def test_no_link_returns_empty(self): + client = _make_client() + with patch.object(client, "_get_versions_link", return_value=None): + self.assertEqual(client.get_object_versions("/uri"), []) + + def test_relative_href_builds_full_url(self): + client = _make_client() + with patch.object(client, "_get_versions_link", + return_value="/sap/.../versions"), \ + patch.object(client, "_parse_versions", return_value=[{"version": "0001"}]) as mock_parse: + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML) + result = client.get_object_versions("/uri") + url = client.session.get.call_args.args[0] + self.assertTrue(url.startswith("https://sap.example.com/sap/")) + self.assertEqual(result, [{"version": "0001"}]) + mock_parse.assert_called_once() + + def test_absolute_href_used_as_is(self): + client = _make_client() + with patch.object(client, "_get_versions_link", + return_value="http://other.host/versions"): + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_FEED_XML) + client.get_object_versions("/uri") + url = client.session.get.call_args.args[0] + self.assertEqual(url, "http://other.host/versions") + + +class TestGetVersionsLink(unittest.TestCase): + + def test_finds_versions_link(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=_VERSIONS_LINK_XML) + href = client._get_versions_link("/uri") + self.assertEqual(href, "/sap/bc/adt/oo/classes/ztest/versions") + + def test_non_200_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(404) + self.assertIsNone(client._get_versions_link("/uri")) + + def test_unparseable_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=b"<>") + self.assertIsNone(client._get_versions_link("/uri")) + + def test_no_versions_link_returns_none(self): + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + self.assertIsNone(client._get_versions_link("/uri")) + + def test_empty_href_skipped(self): + """rel 含 versions 但 href 为空 → 跳过,返回 None。""" + client = _make_client() + client.session.get.return_value = _mock_resp(200, content=( + f'' + f'' + f'' + ).encode()) + self.assertIsNone(client._get_versions_link("/uri")) + + +class TestParseVersions(unittest.TestCase): + + def test_unparseable_returns_empty(self): + self.assertEqual(_make_client()._parse_versions(b"<>"), []) + + def test_feed_with_entries(self): + result = _make_client()._parse_versions(_VERSIONS_FEED_XML) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["version"], "0001") # versionId 别名 → version + self.assertEqual(result[0]["author"], "DEVUSER") + self.assertEqual(result[0]["date"], "2026-06-10T14:20:00") + self.assertEqual(result[0]["versionTitle"], "Initial") + self.assertEqual(result[1]["version"], "0002") + + def test_fallback_no_entries(self): + """无 entry 但含 version/versionId 属性的元素 → 各成一行。""" + xml = ( + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_versions(xml) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["version"], "0001") + self.assertEqual(result[0]["author"], "DEV") + self.assertEqual(result[1]["version"], "0002") + + +# ═══════════════════════════════════════════ +# get_enhancements + _parse_enhancements +# ═══════════════════════════════════════════ + +_ENHANCEMENTS_XML = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' +).encode() + + +class TestGetEnhancements(unittest.TestCase): + + def test_success_returns_parsed(self): + client = _make_client() + client.session.post.return_value = _mock_resp(200, content=_ENHANCEMENTS_XML) + result = client.get_enhancements("/sap/bc/adt/oo/classes/zcl_test") + self.assertEqual(len(result), 2) + # body 含 obj_uri + _, kwargs = client.session.post.call_args + self.assertIn("/sap/bc/adt/oo/classes/zcl_test", kwargs["data"].decode("utf-8")) + + def test_http_error_propagates(self): + client = _make_client() + mock = _mock_resp(500) + mock.raise_for_status.side_effect = Exception("500") + client.session.post.return_value = mock + with self.assertRaises(Exception): + client.get_enhancements("/uri") + + +class TestParseEnhancements(unittest.TestCase): + + def test_unparseable_returns_empty(self): + self.assertEqual(_make_client()._parse_enhancements(b"<>"), []) + + def test_full_parse(self): + result = _make_client()._parse_enhancements(_ENHANCEMENTS_XML) + self.assertEqual(len(result), 2) + + first = result[0] + self.assertEqual(first["name"], "Z_ENH1") + self.assertEqual(first["type"], "ENHO") + self.assertEqual(first["enhanced_name"], "ZCL_TARGET") + self.assertEqual(first["enhanced_type"], "CLAS/OC") + self.assertEqual(len(first["elements"]), 1) + el = first["elements"][0] + self.assertEqual(el["name"], "method1") + self.assertEqual(el["type"], "METHOD") + self.assertEqual(el["mode"], "overwrite") + self.assertEqual(el["replacing"], "OLD_M") + + # 第二项用 enhanced 回退 + sourceCodePluginElement + second = result[1] + self.assertEqual(second["enhanced_name"], "ZIF_TARGET") + self.assertEqual(second["enhanced_type"], "INTF/OI") + self.assertEqual(second["elements"][0]["name"], "impl1") + self.assertEqual(second["elements"][0]["type"], "INCLUDE") + + def test_enho_type_matching_via_type_attr(self): + """type 属性含 ENHO 但标签名非标准 → 也能匹配。""" + xml = ( + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "Z_X") + self.assertEqual(result[0]["elements"][0]["name"], "m") + + def test_element_with_plain_name_type_fallback(self): + """pluginElement 用 name/type 而非 elementName/elementType。""" + xml = ( + '' + '' + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(result[0]["elements"][0]["name"], "plain") + self.assertEqual(result[0]["elements"][0]["type"], "METHOD") + + def test_enhancement_without_enhanced_object(self): + """无 enhancedObject/enhanced → enhanced_* 为空字符串。""" + xml = ( + '' + '' + '' + '' + ).encode() + result = _make_client()._parse_enhancements(xml) + self.assertEqual(result[0]["enhanced_name"], "") + self.assertEqual(result[0]["enhanced_type"], "") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_enhancement.py b/tests/unit/test_enhancement.py new file mode 100644 index 0000000..8553ae8 --- /dev/null +++ b/tests/unit/test_enhancement.py @@ -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) diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py new file mode 100644 index 0000000..9a073ea --- /dev/null +++ b/tests/unit/test_history.py @@ -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) diff --git a/tests/unit/test_modules.py b/tests/unit/test_modules.py new file mode 100644 index 0000000..e0ee61c --- /dev/null +++ b/tests/unit/test_modules.py @@ -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. + + 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() + diff --git a/tests/unit/test_program_run.py b/tests/unit/test_program_run.py new file mode 100644 index 0000000..69b934e --- /dev/null +++ b/tests/unit/test_program_run.py @@ -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) diff --git a/tests/unit/test_repo_guards.py b/tests/unit/test_repo_guards.py new file mode 100644 index 0000000..514bcaa --- /dev/null +++ b/tests/unit/test_repo_guards.py @@ -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() diff --git a/tests/unit/test_unit_test.py b/tests/unit/test_unit_test.py new file mode 100644 index 0000000..0db9e92 --- /dev/null +++ b/tests/unit/test_unit_test.py @@ -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)