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.
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/.gitignore +4 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/PKG-INFO +17 -1
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/game_bridge.gd +14 -3
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/input_simulation_api.gd +13 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/low_level_api.gd +28 -5
- godot_cli_control-0.2.2/addons/godot_cli_control/tests/gut/test_game_bridge.gd +346 -0
- {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
- {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
- godot_cli_control-0.2.2/pyproject.toml +77 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/README.md +12 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/__init__.py +1 -1
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/_version.py +2 -2
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/cli.py +12 -1
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/daemon.py +10 -1
- godot_cli_control-0.2.2/python/tests/test_bridge.py +429 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_cli.py +32 -0
- godot_cli_control-0.2.2/python/tests/test_cli_helpers.py +297 -0
- godot_cli_control-0.2.2/python/tests/test_daemon.py +749 -0
- godot_cli_control-0.2.2/python/tests/test_pytest_plugin.py +315 -0
- godot_cli_control-0.2.2/python/tests/test_runner.py +158 -0
- godot_cli_control-0.2.1/pyproject.toml +0 -48
- godot_cli_control-0.2.1/python/tests/test_daemon.py +0 -240
- godot_cli_control-0.2.1/python/tests/test_pytest_plugin.py +0 -85
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/LICENSE +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/CHANGELOG.md +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/LICENSE +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/README.md +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.cfg +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.gd +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/plugin.gd.uid +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/run_gut.sh +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/__main__.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/bridge.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/client.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/init_cmd.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/pytest_plugin.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/runner.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/skills_install.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/__init__.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/skill/SKILL.md +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/godot_cli_control/templates/skill/__init__.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/__init__.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_client.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_init.py +0 -0
- {godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/python/tests/test_skills_install.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godot-cli-control
|
|
3
|
-
Version: 0.2.
|
|
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.
|
{godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/game_bridge.gd
RENAMED
|
@@ -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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
{godot_cli_control-0.2.1 → godot_cli_control-0.2.2}/addons/godot_cli_control/bridge/low_level_api.gd
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|