godot-cli-control 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/.gitignore +4 -0
  2. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/PKG-INFO +17 -1
  3. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/game_bridge.gd +14 -3
  4. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/input_simulation_api.gd +13 -0
  5. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/low_level_api.gd +28 -5
  6. godot_cli_control-0.2.2/addons/godot_cli_control/tests/gut/test_game_bridge.gd +346 -0
  7. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +62 -0
  8. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +64 -0
  9. godot_cli_control-0.2.2/pyproject.toml +77 -0
  10. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/README.md +12 -0
  11. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/__init__.py +1 -1
  12. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/_version.py +2 -2
  13. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/cli.py +12 -1
  14. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/daemon.py +10 -1
  15. godot_cli_control-0.2.2/python/tests/test_bridge.py +429 -0
  16. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_cli.py +32 -0
  17. godot_cli_control-0.2.2/python/tests/test_cli_helpers.py +297 -0
  18. godot_cli_control-0.2.2/python/tests/test_daemon.py +749 -0
  19. godot_cli_control-0.2.2/python/tests/test_pytest_plugin.py +315 -0
  20. godot_cli_control-0.2.2/python/tests/test_runner.py +158 -0
  21. godot_cli_control-0.2.1/pyproject.toml +0 -48
  22. godot_cli_control-0.2.1/python/tests/test_daemon.py +0 -240
  23. godot_cli_control-0.2.1/python/tests/test_pytest_plugin.py +0 -85
  24. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/LICENSE +0 -0
  25. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/CHANGELOG.md +0 -0
  26. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/LICENSE +0 -0
  27. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/README.md +0 -0
  28. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  29. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  30. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  31. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  32. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  33. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.cfg +0 -0
  34. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.gd +0 -0
  35. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.gd.uid +0 -0
  36. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  37. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  38. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  39. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/__main__.py +0 -0
  40. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/bridge.py +0 -0
  41. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/client.py +0 -0
  42. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/init_cmd.py +0 -0
  43. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/pytest_plugin.py +0 -0
  44. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/runner.py +0 -0
  45. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/skills_install.py +0 -0
  46. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/__init__.py +0 -0
  47. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/skill/SKILL.md +0 -0
  48. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  49. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/__init__.py +0 -0
  50. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_client.py +0 -0
  51. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_init.py +0 -0
  52. {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_skills_install.py +0 -0
@@ -8,6 +8,10 @@ dist/
8
8
  .venv/
9
9
  venv/
10
10
  .pytest_cache/
11
+ .coverage
12
+ .coverage.*
13
+ coverage.xml
14
+ htmlcov/
11
15
 
12
16
  # hatch-vcs generated
13
17
  python/godot_cli_control/_version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -9,6 +9,10 @@ Requires-Python: >=3.10
9
9
  Requires-Dist: websockets<16,>=14
10
10
  Provides-Extra: pytest
11
11
  Requires-Dist: pytest>=7; extra == 'pytest'
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
14
+ Requires-Dist: pytest-cov>=4; extra == 'test'
15
+ Requires-Dist: pytest>=7; extra == 'test'
12
16
  Description-Content-Type: text/markdown
13
17
 
14
18
  # godot-cli-control
@@ -161,6 +165,18 @@ fi
161
165
 
162
166
  The port is read from `.cli_control/port` if you don't pass `--port`, so RPC calls just work after `daemon start`.
163
167
 
168
+ ## Testing
169
+
170
+ ```bash
171
+ # Python unit tests + coverage (fails if below 80%)
172
+ pip install -e ".[test]"
173
+ coverage run -m pytest python/tests/
174
+ coverage report
175
+
176
+ # GUT tests for the Godot plugin (needs GODOT_BIN env var)
177
+ GODOT_BIN=/path/to/godot ./addons/godot_cli_control/tests/run_gut.sh
178
+ ```
179
+
164
180
  ## Documentation
165
181
 
166
182
  See the [Godot plugin README](https://github.com/ClaymanTwinkle/godot-cli-control/blob/main/addons/godot_cli_control/README.md) for the full RPC reference, activation modes, security model, and known limitations.
@@ -46,7 +46,12 @@ func _ready() -> void:
46
46
  # 安全:显式绑 127.0.0.1,避免 Godot TCPServer 默认 "*" 暴露到 LAN
47
47
  var err: Error = _tcp_server.listen(_port, "127.0.0.1")
48
48
  if err != OK:
49
- push_error("GameBridge: Failed to listen on port %d: %s" % [_port, error_string(err)])
49
+ # push_error headless 下只进 Godot 内部 log,不上 stderr。daemon
50
+ # subprocess 看不到 root cause,超时 30s 后只能报 "GameBridge not ready"。
51
+ # printerr 直接写 stderr,subprocess 默认透传 → 用户立刻看到端口冲突。
52
+ var msg: String = "GameBridge: Failed to listen on port %d: %s" % [_port, error_string(err)]
53
+ push_error(msg)
54
+ printerr(msg)
50
55
  return
51
56
  print("GameBridge: Listening on ws://127.0.0.1:%d" % _port)
52
57
 
@@ -176,8 +181,14 @@ func _handle_message(raw: String) -> void:
176
181
 
177
182
 
178
183
  func _run_async(id: String, handler: Callable, params: Dictionary) -> void:
179
- var result: Dictionary = await handler.call(params)
180
- _dispatch_result(id, result)
184
+ # 不能直接 `as Dictionary` —— handler 返回 null 或非 dict 会强转成 null,
185
+ # 后续 `result.has("error")` 在 null 上引擎错误,**响应永远不会发出**,
186
+ # 客户端 await 挂死到 timeout。先收原 Variant 自己 type-check。
187
+ var raw: Variant = await handler.call(params)
188
+ if not raw is Dictionary:
189
+ _send_error(id, -32603, "internal: async handler returned non-dict")
190
+ return
191
+ _dispatch_result(id, raw as Dictionary)
181
192
 
182
193
 
183
194
  func _dispatch_result(id: String, result: Dictionary) -> void:
@@ -48,6 +48,8 @@ func handle_action_press(params: Dictionary) -> Dictionary:
48
48
  if _combo_active:
49
49
  return _err(1004, "combo in progress")
50
50
  var action: String = params.get("action", "") as String
51
+ if not InputMap.has_action(action):
52
+ return _err(1003, "Unknown action: %s" % action)
51
53
  _do_press(action)
52
54
  _pressed_actions[action] = true
53
55
  return {"success": true}
@@ -57,6 +59,8 @@ func handle_action_release(params: Dictionary) -> Dictionary:
57
59
  if _combo_active:
58
60
  return _err(1004, "combo in progress")
59
61
  var action: String = params.get("action", "") as String
62
+ if not InputMap.has_action(action):
63
+ return _err(1003, "Unknown action: %s" % action)
60
64
  _do_release(action)
61
65
  _pressed_actions.erase(action)
62
66
  _held_actions.erase(action)
@@ -69,6 +73,8 @@ func handle_action_tap(params: Dictionary) -> Dictionary:
69
73
  if _combo_active:
70
74
  return _err(1004, "combo in progress")
71
75
  var action: String = params.get("action", "") as String
76
+ if not InputMap.has_action(action):
77
+ return _err(1003, "Unknown action: %s" % action)
72
78
  var duration: float = params.get("duration", 0.1) as float
73
79
  _do_press(action)
74
80
  _held_actions[action] = duration
@@ -100,6 +106,8 @@ func handle_hold(params: Dictionary) -> Dictionary:
100
106
  if _combo_active:
101
107
  return _err(1004, "combo in progress")
102
108
  var action: String = params.get("action", "") as String
109
+ if not InputMap.has_action(action):
110
+ return _err(1003, "Unknown action: %s" % action)
103
111
  var duration: float = params.get("duration", 0.0) as float
104
112
  _do_press(action)
105
113
  _held_actions[action] = duration
@@ -188,6 +196,11 @@ func _begin_combo_step() -> void:
188
196
  _combo_timer = step["wait"] as float
189
197
  elif step.has("action"):
190
198
  var action: String = step["action"] as String
199
+ if not InputMap.has_action(action):
200
+ _abort_combo_with_error(
201
+ 1003, "Unknown action at combo step %d: %s" % [_combo_index, action]
202
+ )
203
+ return
191
204
  var duration: float = step.get("duration", 0.1) as float
192
205
  _do_press(action)
193
206
  _held_actions[action] = duration
@@ -3,6 +3,10 @@ extends Node
3
3
  ## 低层 API:通用节点操作(click、属性、场景树等)
4
4
 
5
5
  const _BUILD_TREE_HARD_LIMIT: int = 50
6
+ # 总节点数上限:宽场景(1000+ 子项的 Grid/Container)会构造极大 JSON,
7
+ # 超 outbound buffer(默认 10 MB)后客户端拿到截断包 → STATE_CLOSED 断连。
8
+ # 5000 节点对应 ~500 KB JSON,留足余量。
9
+ const _BUILD_TREE_NODE_LIMIT: int = 5000
6
10
  # wait_game_time_async 防呆上限:防止误传 1e9 之类的数值挂死 session
7
11
  const _MAX_WAIT_SECONDS: float = 3600.0
8
12
 
@@ -99,7 +103,12 @@ func handle_set_property(params: Dictionary) -> Dictionary:
99
103
  var property: String = params.get("property", "") as String
100
104
  if property.is_empty():
101
105
  return _err(-32602, "Missing 'property' parameter")
102
- if property in _property_blacklist:
106
+ # Godot Object.set() 接受 NodePath 形式的子属性(如 "position:x")。
107
+ # 精确字符串黑名单会漏掉 "script:source_code" / "texture:resource_path" 这类
108
+ # 嵌套写入向量 —— 拿 ":" 前的 top-level 名重新过一次黑名单。
109
+ # 同时整串也走一次(防御深度,万一未来加非冒号语法的反射子路径)。
110
+ var top_level: String = property.split(":", true, 1)[0]
111
+ if property in _property_blacklist or top_level in _property_blacklist:
103
112
  return _err(-32602, "Blocked property: %s" % property)
104
113
  var value: Variant = params.get("value", null)
105
114
  node.set(property, value)
@@ -169,7 +178,15 @@ func handle_get_scene_tree(params: Dictionary) -> Dictionary:
169
178
  var root: Node = get_tree().current_scene
170
179
  if root == null:
171
180
  root = get_tree().root
172
- var tree: Dictionary = _build_tree(root, max_depth, 0)
181
+ # counter Array[int] by-ref 计数器:GDScript 没指针/inout,
182
+ # Array 是引用类型,递归子调用对 counter[0] 的写入对调用方可见。
183
+ var counter: Array[int] = [0]
184
+ var tree: Dictionary = _build_tree(root, max_depth, 0, counter)
185
+ if counter[0] > _BUILD_TREE_NODE_LIMIT:
186
+ return _err(
187
+ 1004,
188
+ "scene tree too large (>%d nodes); lower 'depth' or query a subtree" % _BUILD_TREE_NODE_LIMIT,
189
+ )
173
190
  return {"tree": tree}
174
191
 
175
192
 
@@ -237,8 +254,11 @@ func _has_property(node: Node, property: String) -> bool:
237
254
  return false
238
255
 
239
256
 
240
- ## depth=0 表示"无限深度",使用硬限制 _BUILD_TREE_HARD_LIMIT (50) 防止无限递归
241
- func _build_tree(node: Node, max_depth: int, current_depth: int) -> Dictionary:
257
+ ## depth=0 表示"无限深度",使用硬限制 _BUILD_TREE_HARD_LIMIT (50) 防止无限递归。
258
+ ## counter by-ref 计数器:超过 _BUILD_TREE_NODE_LIMIT 时短路(不再递归子节点),
259
+ ## 调用方读 counter[0] > LIMIT 决定是否走 1004 错误路径。
260
+ func _build_tree(node: Node, max_depth: int, current_depth: int, counter: Array[int]) -> Dictionary:
261
+ counter[0] += 1
242
262
  var entry: Dictionary = {
243
263
  "name": node.name,
244
264
  "type": node.get_class(),
@@ -248,10 +268,13 @@ func _build_tree(node: Node, max_depth: int, current_depth: int) -> Dictionary:
248
268
  entry["visible"] = (node as CanvasItem).visible
249
269
  if "text" in node:
250
270
  entry["text"] = str(node.get("text"))
271
+ if counter[0] > _BUILD_TREE_NODE_LIMIT:
272
+ # 超限:不再下递归,但当前 entry 已计入;调用方一次性走错误返回
273
+ return entry
251
274
  var effective_max: int = _BUILD_TREE_HARD_LIMIT if max_depth == 0 else max_depth
252
275
  if current_depth < effective_max:
253
276
  var children: Array[Dictionary] = []
254
277
  for child: Node in node.get_children():
255
- children.append(_build_tree(child, effective_max, current_depth + 1))
278
+ children.append(_build_tree(child, effective_max, current_depth + 1, counter))
256
279
  entry["children"] = children
257
280
  return entry
@@ -0,0 +1,346 @@
1
+ ## GUT 单元测试:GameBridge JSON-RPC 路由器
2
+ ##
3
+ ## 策略:测试覆盖 _handle_message 的 dispatch 全部分支 —— 不起 TCPServer / WebSocket,
4
+ ## 不进 scene tree(避免 _ready 跑 listen()/queue_free)。
5
+ ## 1. TestableGameBridge.new() 是 orphan Node;_ready 不触发。
6
+ ## 2. 手动塞 _low_level_api / _input_sim_api(StubLowLevelApi / StubInputSimulationApi)
7
+ ## 并调 _register_methods(),让方法表指向 stub 的 handler。
8
+ ## 3. 子类 override _send_json 把出站帧捕获到 captured_frames,
9
+ ## 跳过 _active_peer 状态检查这一现实依赖。
10
+ ## 4. 直接调 _handle_message(raw_json_string) 触发路由;async 路径用
11
+ ## await get_tree().process_frame 等待 stub 通过 callback 回响。
12
+ ##
13
+ ## 这套测试用来拦的回归(最近三个 commit 都改这一带,黑盒测了但缺单测):
14
+ ## - b1f2ec9: NodePath 子属性黑名单(实际由 LowLevelApi 测 —— bridge 只测路由)
15
+ ## - e7b9768: async type-check(async handler 返回 null/非 dict 必须发 -32603 而非挂死)
16
+ ## - b654259: id/method/params 类型严校验
17
+ extends GutTest
18
+
19
+ const GameBridgeScript := preload("res://addons/godot_cli_control/bridge/game_bridge.gd")
20
+ const LowLevelApiScript := preload("res://addons/godot_cli_control/bridge/low_level_api.gd")
21
+ const InputSimulationApiScript := preload("res://addons/godot_cli_control/bridge/input_simulation_api.gd")
22
+
23
+
24
+ # ── 子类:捕获 _send_json 出站 + 跳过 peer 状态检查 ──────────────────
25
+
26
+ class TestableGameBridge:
27
+ extends GameBridge
28
+ var captured_frames: Array = []
29
+
30
+ func _send_json(data: Dictionary) -> void:
31
+ # 不检查 _active_peer —— 测试场景里它就是 null。直接把帧记下来。
32
+ captured_frames.append(data)
33
+
34
+
35
+ # ── 桩 LowLevelApi:sync / async handler 各覆盖 1 个,返回值由测试预置 ──
36
+
37
+ class StubLowLevelApi:
38
+ extends LowLevelApi
39
+ # 每个 handler 的预置返回值 + 调用记录
40
+ var click_return: Dictionary = {"success": true}
41
+ var click_calls: Array = []
42
+ # 父类签名 -> Dictionary 是静态类型契约,override 不能放宽到 Variant;
43
+ # async type-guard(返回非 dict)由测试通过外部 Callable 注入到 _methods。
44
+ var wait_for_node_return: Dictionary = {"found": true}
45
+ var wait_for_node_calls: Array = []
46
+
47
+ func handle_click(params: Dictionary) -> Dictionary:
48
+ click_calls.append(params)
49
+ return click_return
50
+
51
+ func wait_for_node_async(params: Dictionary) -> Dictionary:
52
+ wait_for_node_calls.append(params)
53
+ # 推一帧让调用方真的走 await 路径
54
+ await get_tree().process_frame
55
+ return wait_for_node_return
56
+
57
+
58
+ # ── 桩 InputSimulationApi:sync handler + async_with_id(combo) ──
59
+
60
+ class StubInputSimulationApi:
61
+ extends InputSimulationApi
62
+ var press_return: Dictionary = {"success": true}
63
+ var press_calls: Array = []
64
+ # combo 控制:测试通过 finish_combo() 决定何时回响 + 返回什么
65
+ var combo_calls: Array = [] # [{params, request_id}]
66
+ var combo_callback: Callable = Callable()
67
+
68
+ func setup(send_response: Callable) -> void:
69
+ # GameBridge._ready 调 setup() 时不会跑(orphan 实例),但
70
+ # _register_methods 之前测试会手动调一次。
71
+ combo_callback = send_response
72
+
73
+ func handle_action_press(params: Dictionary) -> Dictionary:
74
+ press_calls.append(params)
75
+ return press_return
76
+
77
+ func handle_combo(params: Dictionary, request_id: String) -> void:
78
+ # 不立刻回响 —— 把 (params, request_id) 记下来;测试通过 finish_combo
79
+ # 显式触发 callback,模拟真实 combo 完成路径。
80
+ combo_calls.append({"params": params, "request_id": request_id})
81
+
82
+ func finish_combo(index: int, result: Dictionary) -> void:
83
+ var entry: Dictionary = combo_calls[index]
84
+ combo_callback.call(entry["request_id"], result)
85
+
86
+
87
+ # ── 测试夹具 ────────────────────────────────────────────────────────
88
+
89
+ var _bridge: TestableGameBridge
90
+ var _low: StubLowLevelApi
91
+ var _input: StubInputSimulationApi
92
+
93
+
94
+ func before_each() -> void:
95
+ _bridge = TestableGameBridge.new()
96
+ # orphan:不 add_child 到 tree → _ready 不触发 → 跳过 listen() / queue_free
97
+ autofree(_bridge)
98
+
99
+ # 但 stub APIs 需要在 tree 内才能 await get_tree().process_frame
100
+ _low = StubLowLevelApi.new()
101
+ _low.name = "LowLevelApi"
102
+ add_child_autofree(_low)
103
+ _input = StubInputSimulationApi.new()
104
+ _input.name = "InputSimulationApi"
105
+ add_child_autofree(_input)
106
+
107
+ _bridge._low_level_api = _low
108
+ _bridge._input_sim_api = _input
109
+ # InputSim 的 callback:bridge 的 _on_async_response 把 (id, result) 转回 dispatch
110
+ _input.setup(_bridge._on_async_response)
111
+ _bridge._register_methods()
112
+
113
+
114
+ # ── helper ──
115
+
116
+ func _send(raw: String) -> void:
117
+ _bridge._handle_message(raw)
118
+
119
+
120
+ func _last_frame() -> Dictionary:
121
+ assert_true(_bridge.captured_frames.size() > 0, "应该至少有一个出站帧")
122
+ return _bridge.captured_frames[-1]
123
+
124
+
125
+ # ── JSON / 协议层校验:-32600 ──────────────────────────────────────
126
+
127
+ func test_invalid_json_emits_minus_32600_with_empty_id() -> void:
128
+ _send("not valid json {{")
129
+ var f: Dictionary = _last_frame()
130
+ assert_eq(str(f.get("id", "MISSING")), "")
131
+ assert_has(f, "error")
132
+ assert_eq(int(f.error.code), -32600)
133
+ assert_string_contains(str(f.error.message), "Invalid JSON")
134
+
135
+
136
+ func test_non_dict_root_emits_minus_32600() -> void:
137
+ # 合法 JSON 但顶层是 array —— 不是 RPC 请求
138
+ _send("[1, 2, 3]")
139
+ var f: Dictionary = _last_frame()
140
+ assert_eq(int(f.error.code), -32600)
141
+
142
+
143
+ func test_id_non_string_emits_minus_32600_with_empty_id() -> void:
144
+ # id 是数字时无法回响给客户端正确的 id,强制空串 + 协议错
145
+ _send('{"id": 42, "method": "click", "params": {}}')
146
+ var f: Dictionary = _last_frame()
147
+ assert_eq(str(f.get("id")), "", "id 非字符串时响应必须用空串而非数字")
148
+ assert_eq(int(f.error.code), -32600)
149
+ assert_string_contains(str(f.error.message), "id must be string")
150
+
151
+
152
+ func test_method_non_string_emits_minus_32600() -> void:
153
+ _send('{"id": "x", "method": 123, "params": {}}')
154
+ var f: Dictionary = _last_frame()
155
+ assert_eq(str(f.id), "x")
156
+ assert_eq(int(f.error.code), -32600)
157
+ assert_string_contains(str(f.error.message), "method must be string")
158
+
159
+
160
+ func test_method_empty_emits_minus_32600() -> void:
161
+ _send('{"id": "x", "method": "", "params": {}}')
162
+ var f: Dictionary = _last_frame()
163
+ assert_eq(int(f.error.code), -32600)
164
+ assert_string_contains(str(f.error.message), "Missing method")
165
+
166
+
167
+ func test_params_non_dict_emits_minus_32600() -> void:
168
+ # params 是 array —— handler 内 .get 会崩,必须在路由层挡住
169
+ _send('{"id": "x", "method": "click", "params": [1, 2]}')
170
+ var f: Dictionary = _last_frame()
171
+ assert_eq(int(f.error.code), -32600)
172
+ assert_string_contains(str(f.error.message), "params must be object")
173
+
174
+
175
+ func test_params_missing_treated_as_empty_dict() -> void:
176
+ # params 缺失 → handler 拿到空 dict(合法),不应报协议错
177
+ _send('{"id": "x", "method": "click"}')
178
+ var f: Dictionary = _last_frame()
179
+ assert_does_not_have(f, "error")
180
+ assert_eq(_low.click_calls.size(), 1)
181
+ assert_eq(_low.click_calls[0].size(), 0, "params 缺失应等价空 dict")
182
+
183
+
184
+ func test_id_missing_defaults_to_empty_string() -> void:
185
+ # 客户端用 "" 当 fire-and-forget id;缺省也走这条路径,响应 id 也是 ""
186
+ _send('{"method": "click", "params": {}}')
187
+ var f: Dictionary = _last_frame()
188
+ assert_eq(str(f.get("id")), "")
189
+ assert_does_not_have(f, "error")
190
+
191
+
192
+ # ── 方法层校验:-32601 ────────────────────────────────────────────
193
+
194
+ func test_unknown_method_emits_minus_32601() -> void:
195
+ _send('{"id": "x", "method": "no_such_method", "params": {}}')
196
+ var f: Dictionary = _last_frame()
197
+ assert_eq(int(f.error.code), -32601)
198
+ assert_string_contains(str(f.error.message), "Unknown method")
199
+
200
+
201
+ # ── sync 路径 ─────────────────────────────────────────────────────
202
+
203
+ func test_sync_handler_success_emits_result_frame() -> void:
204
+ _low.click_return = {"success": true, "node_class": "Button"}
205
+ _send('{"id": "abc", "method": "click", "params": {"path": "/root/Btn"}}')
206
+ var f: Dictionary = _last_frame()
207
+ assert_eq(str(f.id), "abc")
208
+ assert_has(f, "result")
209
+ assert_does_not_have(f, "error")
210
+ assert_eq(f.result.success, true)
211
+ assert_eq(str(f.result.node_class), "Button")
212
+ # 参数透传到 stub
213
+ assert_eq(_low.click_calls[0].get("path"), "/root/Btn")
214
+
215
+
216
+ func test_sync_handler_error_dict_emits_error_frame() -> void:
217
+ # handler 主动返回 {"error": {...}} —— _dispatch_result 应识别并发 error 帧
218
+ _low.click_return = {"error": {"code": 1001, "message": "Node not found"}}
219
+ _send('{"id": "x", "method": "click", "params": {"path": "/missing"}}')
220
+ var f: Dictionary = _last_frame()
221
+ assert_has(f, "error")
222
+ assert_does_not_have(f, "result", "error 路径不应同时带 result")
223
+ assert_eq(int(f.error.code), 1001)
224
+ assert_eq(str(f.error.message), "Node not found")
225
+
226
+
227
+ # ── async 路径 ────────────────────────────────────────────────────
228
+
229
+ func test_async_handler_success_emits_result_frame() -> void:
230
+ _low.wait_for_node_return = {"found": true}
231
+ _send('{"id": "wait1", "method": "wait_for_node", "params": {"path": "/X", "timeout": 1.0}}')
232
+ # stub 的 await get_tree().process_frame 让响应延后一帧;推两帧足够
233
+ await get_tree().process_frame
234
+ await get_tree().process_frame
235
+ var f: Dictionary = _last_frame()
236
+ assert_eq(str(f.id), "wait1")
237
+ assert_has(f, "result")
238
+ assert_eq(f.result.get("found"), true)
239
+
240
+
241
+ func _async_returning_null(_params: Dictionary) -> Variant:
242
+ # 故意返回非 Dictionary 触发 _run_async 的 type-guard。
243
+ # 父类 LowLevelApi.wait_for_node_async 签名锁死 -> Dictionary,没法在 stub 里
244
+ # 直接 override 类型放宽,绕道:测试通过 Callable 注入 _methods 项。
245
+ await get_tree().process_frame
246
+ return null
247
+
248
+
249
+ func _async_returning_string(_params: Dictionary) -> Variant:
250
+ await get_tree().process_frame
251
+ return "oops"
252
+
253
+
254
+ func test_async_handler_returning_non_dict_emits_minus_32603() -> void:
255
+ # 关键回归(commit e7b9768):async handler 返回 null / 字符串 → 必须发
256
+ # -32603,不能让响应永远不发,否则客户端 await 挂死到 30s timeout。
257
+ _bridge._methods["wait_for_node"] = {
258
+ "callable": _async_returning_null,
259
+ "kind": "async",
260
+ }
261
+ _send('{"id": "wait_null", "method": "wait_for_node", "params": {}}')
262
+ await get_tree().process_frame
263
+ await get_tree().process_frame
264
+ var f: Dictionary = _last_frame()
265
+ assert_eq(str(f.id), "wait_null")
266
+ assert_has(f, "error", "async handler 返回 null 必须落到 -32603 错误")
267
+ assert_eq(int(f.error.code), -32603)
268
+ assert_string_contains(str(f.error.message), "non-dict")
269
+
270
+
271
+ func test_async_handler_returning_string_emits_minus_32603() -> void:
272
+ # 防御性:handler 不小心 return "" 也走 type-guard
273
+ _bridge._methods["wait_for_node"] = {
274
+ "callable": _async_returning_string,
275
+ "kind": "async",
276
+ }
277
+ _send('{"id": "wait_str", "method": "wait_for_node", "params": {}}')
278
+ await get_tree().process_frame
279
+ await get_tree().process_frame
280
+ var f: Dictionary = _last_frame()
281
+ assert_eq(int(f.error.code), -32603)
282
+
283
+
284
+ # ── async_with_id 路径(input_combo) ──────────────────────────────
285
+
286
+ func test_async_with_id_routes_request_id_to_handler() -> void:
287
+ _send('{"id": "combo1", "method": "input_combo", "params": {"steps": [{"action": "a", "duration": 0.1}]}}')
288
+ # handler 不立即响应:captured_frames 应为空
289
+ assert_eq(_bridge.captured_frames.size(), 0, "input_combo 是 async_with_id,不应同步发响应")
290
+ assert_eq(_input.combo_calls.size(), 1)
291
+ assert_eq(str(_input.combo_calls[0].request_id), "combo1")
292
+ # 触发 callback 回响
293
+ _input.finish_combo(0, {"success": true, "completed_steps": 1})
294
+ var f: Dictionary = _last_frame()
295
+ assert_eq(str(f.id), "combo1")
296
+ assert_has(f, "result")
297
+ assert_eq(int(f.result.completed_steps), 1)
298
+
299
+
300
+ func test_async_with_id_error_callback_emits_error_frame() -> void:
301
+ # combo handler 通过 callback 回 error dict → bridge 应转成 error 帧
302
+ _send('{"id": "combo_err", "method": "input_combo", "params": {"steps": []}}')
303
+ _input.finish_combo(0, {"error": {"code": 1004, "message": "combo in progress"}})
304
+ var f: Dictionary = _last_frame()
305
+ assert_eq(str(f.id), "combo_err")
306
+ assert_has(f, "error")
307
+ assert_eq(int(f.error.code), 1004)
308
+
309
+
310
+ func test_async_with_id_id_isolation_across_concurrent_requests() -> void:
311
+ # 三个 combo 请求并发,回调乱序触发:每条响应必须用对应的原始 id
312
+ _send('{"id": "c1", "method": "input_combo", "params": {"steps": []}}')
313
+ _send('{"id": "c2", "method": "input_combo", "params": {"steps": []}}')
314
+ _send('{"id": "c3", "method": "input_combo", "params": {"steps": []}}')
315
+ assert_eq(_input.combo_calls.size(), 3)
316
+ # 乱序回响
317
+ _input.finish_combo(1, {"success": true, "marker": "second"})
318
+ _input.finish_combo(0, {"success": true, "marker": "first"})
319
+ _input.finish_combo(2, {"success": true, "marker": "third"})
320
+ # 收集 (id, marker) 对,验证配对正确
321
+ var pairs: Dictionary = {}
322
+ for f in _bridge.captured_frames:
323
+ pairs[str(f.id)] = str((f.result as Dictionary).get("marker", ""))
324
+ assert_eq(pairs.get("c1"), "first")
325
+ assert_eq(pairs.get("c2"), "second")
326
+ assert_eq(pairs.get("c3"), "third")
327
+
328
+
329
+ # ── 边界:空 id 仍是合法的 fire-and-forget ─────────────────────────
330
+
331
+ func test_empty_id_fire_and_forget_still_routes() -> void:
332
+ # id="" 是合约:客户端不等响应,但 bridge 仍按协议发响应(带 id="")
333
+ _send('{"id": "", "method": "click", "params": {"path": "/x"}}')
334
+ var f: Dictionary = _last_frame()
335
+ assert_eq(str(f.id), "")
336
+ assert_has(f, "result")
337
+
338
+
339
+ # ── 注册表完整性:断 sync 实际有路由(防 _register_methods 退化) ────
340
+
341
+ func test_sync_input_action_press_routes_to_input_sim_api() -> void:
342
+ _send('{"id": "p1", "method": "input_action_press", "params": {"action": "jump"}}')
343
+ var f: Dictionary = _last_frame()
344
+ assert_does_not_have(f, "error")
345
+ assert_eq(_input.press_calls.size(), 1)
346
+ assert_eq(str(_input.press_calls[0].action), "jump")
@@ -8,11 +8,25 @@ const InputSimulationApiScript := preload("res://addons/godot_cli_control/bridge
8
8
 
9
9
  var _api: Node
10
10
 
11
+ # combo / hold 状态机测试用占位 action。InputMap 不预设这些,由 fixture 注册。
12
+ const _FIXTURE_ACTIONS: Array[String] = ["a", "b", "alpha", "beta"]
13
+
11
14
 
12
15
  func before_each() -> void:
13
16
  _api = InputSimulationApiScript.new()
14
17
  _api.name = "InputSimulationApi"
15
18
  add_child_autofree(_api)
19
+ # #25 修复后 handle_action_press/release/hold 会拒未注册 action(1003)。
20
+ # 状态机测试关心 combo / 互斥语义,不验 InputMap 校验,先把 fixture action 注册。
21
+ for name in _FIXTURE_ACTIONS:
22
+ if not InputMap.has_action(name):
23
+ InputMap.add_action(name)
24
+
25
+
26
+ func after_each() -> void:
27
+ for name in _FIXTURE_ACTIONS:
28
+ if InputMap.has_action(name):
29
+ InputMap.erase_action(name)
16
30
 
17
31
 
18
32
  # ── action_press / action_release / action_tap ────────────────────
@@ -34,6 +48,54 @@ func test_tap_uses_held_track_not_pressed() -> void:
34
48
  assert_false("ui_accept" in _api.get_pressed_actions())
35
49
 
36
50
 
51
+ # ── unknown action 校验(不在 InputMap 内) ─────────────────────────
52
+
53
+ func test_press_unknown_action_returns_1003() -> void:
54
+ # 拼错 action 必须立刻返回错误,不能静默成功污染 _pressed_actions
55
+ var bogus: String = "__definitely_not_an_action__"
56
+ assert_false(InputMap.has_action(bogus))
57
+ var result: Dictionary = _api.handle_action_press({"action": bogus})
58
+ assert_has(result, "error")
59
+ assert_eq(int(result.error.code), 1003)
60
+ assert_false(bogus in _api.get_pressed_actions(), "失败时不能进 pressed 列表")
61
+
62
+
63
+ func test_release_unknown_action_returns_1003() -> void:
64
+ var bogus: String = "__not_an_action_release__"
65
+ var result: Dictionary = _api.handle_action_release({"action": bogus})
66
+ assert_has(result, "error")
67
+ assert_eq(int(result.error.code), 1003)
68
+
69
+
70
+ func test_tap_unknown_action_returns_1003() -> void:
71
+ var bogus: String = "__not_an_action_tap__"
72
+ var result: Dictionary = _api.handle_action_tap({"action": bogus, "duration": 0.1})
73
+ assert_has(result, "error")
74
+ assert_eq(int(result.error.code), 1003)
75
+ assert_false(bogus in _api.get_pressed_actions())
76
+
77
+
78
+ func test_hold_unknown_action_returns_1003() -> void:
79
+ var bogus: String = "__not_an_action_hold__"
80
+ var result: Dictionary = _api.handle_hold({"action": bogus, "duration": 1.0})
81
+ assert_has(result, "error")
82
+ assert_eq(int(result.error.code), 1003)
83
+ assert_false(_api.has_active_holds())
84
+
85
+
86
+ func test_combo_unknown_action_aborts_with_1003() -> void:
87
+ # combo step 引用未注册 action:必须 abort 整盘并通过 request_id 回 1003,
88
+ # 否则 _combo_active 卡 true 后续全 1004。
89
+ var probe := _ComboCallbackProbe.new()
90
+ _api.setup(probe.record)
91
+ _api._combo_request_id = "req-bogus"
92
+ _api.start_combo([{"action": "__bogus_action__", "duration": 0.1}])
93
+ assert_false(_api.is_combo_active())
94
+ assert_eq(probe.calls.size(), 1)
95
+ assert_has(probe.calls[0].result, "error")
96
+ assert_eq(int(probe.calls[0].result.error.code), 1003)
97
+
98
+
37
99
  # ── combo 状态机 ──────────────────────────────────────────────────
38
100
 
39
101
  func test_press_blocked_during_combo() -> void: