gdharness 0.9.0 → 0.9.2
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.
- package/build/cli.js +142 -34
- package/build/godot/addons/gdharness_editor/bridge_client.gd +31 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +6 -3
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +35 -15
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +150 -48
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +31 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +8 -6
- package/build/index.js +194 -88
- package/package.json +1 -1
|
@@ -34,9 +34,10 @@ func get_tree(params: Dictionary) -> Dictionary:
|
|
|
34
34
|
var max_depth: int = int(params.get("depth", 3))
|
|
35
35
|
var include_properties: bool = bool(params.get("include_properties", false))
|
|
36
36
|
|
|
37
|
-
var
|
|
38
|
-
if
|
|
39
|
-
return
|
|
37
|
+
var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
|
|
38
|
+
if reached.has("message"):
|
|
39
|
+
return reached
|
|
40
|
+
var root: Node = reached["node"]
|
|
40
41
|
|
|
41
42
|
return {"type": "tree", "root": _serialize_node_tree(root, 0, max_depth, include_properties)}
|
|
42
43
|
|
|
@@ -64,20 +65,28 @@ func find_nodes(params: Dictionary) -> Dictionary:
|
|
|
64
65
|
if not wanted["script"].is_empty() and not wanted["script"].begins_with("res://"):
|
|
65
66
|
wanted["script"] = "res://" + wanted["script"]
|
|
66
67
|
|
|
67
|
-
var
|
|
68
|
-
if
|
|
69
|
-
return
|
|
68
|
+
var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
|
|
69
|
+
if reached.has("message"):
|
|
70
|
+
return reached
|
|
71
|
+
var root: Node = reached["node"]
|
|
70
72
|
|
|
71
73
|
var found: Array[Dictionary] = []
|
|
72
74
|
var pending: Array[Node] = [root]
|
|
73
75
|
var truncated: bool = false
|
|
76
|
+
# Counted while the tree is already being walked, for the answer below: how many nodes this
|
|
77
|
+
# find would have matched if the name had been read the way it was probably meant.
|
|
78
|
+
var literal: bool = _is_literal(wanted["name"])
|
|
79
|
+
var nearly: int = 0
|
|
74
80
|
while not pending.is_empty():
|
|
75
81
|
var node: Node = pending.pop_front()
|
|
76
|
-
|
|
82
|
+
var rest: bool = _matches_apart_from_name(node, wanted)
|
|
83
|
+
if rest and _named(node, wanted["name"]):
|
|
77
84
|
if found.size() >= limit:
|
|
78
85
|
truncated = true
|
|
79
86
|
break
|
|
80
87
|
found.append(_found(node, wanted_property))
|
|
88
|
+
elif rest and literal and str(node.name).containsn(wanted["name"]):
|
|
89
|
+
nearly += 1
|
|
81
90
|
# Internal children included, which they were not. A ConfirmationDialog builds its Yes and
|
|
82
91
|
# its No as internal nodes, and a ScrollContainer its bars, so a find over a screen for
|
|
83
92
|
# every Button came back without the two buttons the player is being asked to press:
|
|
@@ -88,7 +97,17 @@ func find_nodes(params: Dictionary) -> Dictionary:
|
|
|
88
97
|
for index: int in range(children.size() - 1, -1, -1):
|
|
89
98
|
pending.push_front(children[index])
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
|
|
101
|
+
# Nothing found is the one answer that cannot be told apart from having asked the wrong
|
|
102
|
+
# question, and a name written without a wildcard is the way an agent writes "contains".
|
|
103
|
+
# Said only when it changes the answer, so a genuine nothing stays a plain nothing.
|
|
104
|
+
if found.is_empty() and nearly > 0:
|
|
105
|
+
var holding: String = "names contain" if nearly > 1 else "name contains"
|
|
106
|
+
answer["note"] = (
|
|
107
|
+
'name is matched as a glob against the whole name; %d node %s "%s", which "*%s*" would find'
|
|
108
|
+
% [nearly, holding, wanted["name"], wanted["name"]]
|
|
109
|
+
)
|
|
110
|
+
return answer
|
|
92
111
|
|
|
93
112
|
|
|
94
113
|
## One match, with the named property on it when one was named.
|
|
@@ -102,15 +121,20 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
|
|
|
102
121
|
var entry: Dictionary = _serialize_node(node, false)
|
|
103
122
|
if wanted_property.is_empty():
|
|
104
123
|
return entry
|
|
105
|
-
var has: bool = false
|
|
106
|
-
for prop: Dictionary in node.get_property_list():
|
|
107
|
-
if str(prop["name"]) == wanted_property:
|
|
108
|
-
has = true
|
|
109
|
-
break
|
|
110
124
|
entry["property"] = wanted_property
|
|
125
|
+
# Through a path as well, for the reason [method _reached] gives. A step that is not there on
|
|
126
|
+
# this node reads as not having the property rather than as a refusal, which is the rule this
|
|
127
|
+
# whole function is written to: a find matches nodes of several classes on purpose.
|
|
128
|
+
var reached: Dictionary = _reached(node, str(entry["path"]), wanted_property)
|
|
129
|
+
if reached.has("message"):
|
|
130
|
+
entry["has_property"] = false
|
|
131
|
+
return entry
|
|
132
|
+
var holder: Object = reached["holder"]
|
|
133
|
+
var named: String = reached["name"]
|
|
134
|
+
var has: bool = _has_property(holder, named)
|
|
111
135
|
entry["has_property"] = has
|
|
112
136
|
if has:
|
|
113
|
-
entry["value"] = _values.serialize(
|
|
137
|
+
entry["value"] = _values.serialize(holder.get(named))
|
|
114
138
|
return entry
|
|
115
139
|
|
|
116
140
|
|
|
@@ -123,11 +147,24 @@ static func _anything_asked(wanted: Dictionary[String, String]) -> bool:
|
|
|
123
147
|
return false
|
|
124
148
|
|
|
125
149
|
|
|
126
|
-
|
|
150
|
+
## Whether [param node] carries the name a find asked for. Empty matches everything, which is what
|
|
151
|
+
## makes leaving a filter out the same as not having one.
|
|
152
|
+
static func _named(node: Node, pattern: String) -> bool:
|
|
153
|
+
return pattern.is_empty() or str(node.name).matchn(pattern)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## Whether [param pattern] is a name written as a whole word rather than as a glob, which is how a
|
|
157
|
+
## caller writes one when they mean "contains". [method String.matchn] answers nothing to it, and
|
|
158
|
+
## nothing is also what a name that is simply not there answers.
|
|
159
|
+
static func _is_literal(pattern: String) -> bool:
|
|
160
|
+
return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
## Every filter but the name, so a find that came back empty can say how many nodes the name was
|
|
164
|
+
## the only thing standing between it and.
|
|
165
|
+
func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
|
|
127
166
|
if not wanted["group"].is_empty() and not node.is_in_group(wanted["group"]):
|
|
128
167
|
return false
|
|
129
|
-
if not wanted["name"].is_empty() and not str(node.name).matchn(wanted["name"]):
|
|
130
|
-
return false
|
|
131
168
|
# What this node says, rather than everything said underneath it. A row is then found by the
|
|
132
169
|
# label in it, and the path answered is that label's, which is where the words a caller is
|
|
133
170
|
# looking at actually are: matching every container above it would answer with the screen.
|
|
@@ -173,9 +210,10 @@ func read_text(params: Dictionary) -> Dictionary:
|
|
|
173
210
|
var include_hidden: bool = bool(params.get("include_hidden", false))
|
|
174
211
|
var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
|
|
175
212
|
|
|
176
|
-
var
|
|
177
|
-
if
|
|
178
|
-
return
|
|
213
|
+
var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
|
|
214
|
+
if reached.has("message"):
|
|
215
|
+
return reached
|
|
216
|
+
var root: Node = reached["node"]
|
|
179
217
|
|
|
180
218
|
# One line further than asked for, so that whether anything was left behind is read off the
|
|
181
219
|
# walk rather than guessed at from the count: a panel of exactly as many lines as the caller
|
|
@@ -240,9 +278,10 @@ func get_rect(params: Dictionary) -> Dictionary:
|
|
|
240
278
|
if node_path.is_empty():
|
|
241
279
|
return {"type": "error", "message": "Node path required"}
|
|
242
280
|
|
|
243
|
-
var
|
|
244
|
-
if
|
|
245
|
-
return
|
|
281
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
282
|
+
if standing.has("message"):
|
|
283
|
+
return standing
|
|
284
|
+
var node: Node = standing["node"]
|
|
246
285
|
|
|
247
286
|
if node is Control:
|
|
248
287
|
var control: Control = node
|
|
@@ -391,28 +430,71 @@ func get_property(params: Dictionary) -> Dictionary:
|
|
|
391
430
|
if node_path.is_empty() or property.is_empty():
|
|
392
431
|
return {"type": "error", "message": "Node path and property required"}
|
|
393
432
|
|
|
394
|
-
var
|
|
395
|
-
if
|
|
396
|
-
return
|
|
433
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
434
|
+
if standing.has("message"):
|
|
435
|
+
return standing
|
|
436
|
+
var node: Node = standing["node"]
|
|
397
437
|
|
|
438
|
+
var reached: Dictionary = _reached(node, node_path, property)
|
|
439
|
+
if reached.has("message"):
|
|
440
|
+
return reached
|
|
441
|
+
|
|
442
|
+
var holder: Object = reached["holder"]
|
|
443
|
+
var named: String = reached["name"]
|
|
398
444
|
# Asked of the property list rather than read and compared to null, because a property the
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if str(entry["name"]) == property:
|
|
403
|
-
known = true
|
|
404
|
-
break
|
|
405
|
-
if not known:
|
|
406
|
-
return {"type": "error", "message": "%s has no property %s" % [node_path, property]}
|
|
445
|
+
# holder does not have and a property that is null both read as null.
|
|
446
|
+
if not _has_property(holder, named):
|
|
447
|
+
return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
|
|
407
448
|
|
|
408
449
|
return {
|
|
409
450
|
"type": "property",
|
|
410
451
|
"path": node_path,
|
|
411
452
|
"property": property,
|
|
412
|
-
"value": _values.serialize(
|
|
453
|
+
"value": _values.serialize(holder.get(named)),
|
|
413
454
|
}
|
|
414
455
|
|
|
415
456
|
|
|
457
|
+
## What [param reaching] names something on, which is the node itself until the name has a colon
|
|
458
|
+
## in it, and the last name along that path.
|
|
459
|
+
##
|
|
460
|
+
## A game's state does not sit on nodes, it hangs off them: the speed of the clock is a property of
|
|
461
|
+
## a [RefCounted] held by a [RefCounted] held by the root, and that is what an agent asks about.
|
|
462
|
+
## What a game does hangs off them the same way, so the last name is a property to read, a property
|
|
463
|
+
## to write or a method to call, and the walk to it is one walk.
|
|
464
|
+
##
|
|
465
|
+
## Colons, because that is the separator [method Object.get_indexed] already takes. Walked a step
|
|
466
|
+
## at a time rather than handed to that method, which answers null for a path that goes wrong
|
|
467
|
+
## halfway along and for one that ends on null.
|
|
468
|
+
##
|
|
469
|
+
## Answers with a `message` instead when a step along the way is not there or holds something that
|
|
470
|
+
## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
|
|
471
|
+
## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
|
|
472
|
+
func _reached(node: Node, node_path: String, reaching: String) -> Dictionary:
|
|
473
|
+
var parts: PackedStringArray = reaching.split(":")
|
|
474
|
+
var holder: Object = node
|
|
475
|
+
var called: String = node_path
|
|
476
|
+
for step: int in parts.size() - 1:
|
|
477
|
+
var named: String = parts[step]
|
|
478
|
+
if not _has_property(holder, named):
|
|
479
|
+
return {"type": "error", "message": "%s has no property %s" % [called, named]}
|
|
480
|
+
var next: Variant = holder.get(named)
|
|
481
|
+
called = "%s:%s" % [called, named]
|
|
482
|
+
if not next is Object:
|
|
483
|
+
var wanted: String = parts[step + 1]
|
|
484
|
+
return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
|
|
485
|
+
holder = next
|
|
486
|
+
return {"holder": holder, "name": parts[parts.size() - 1], "called": called}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
|
|
490
|
+
## property read back as null answers it wrongly.
|
|
491
|
+
static func _has_property(holder: Object, named: String) -> bool:
|
|
492
|
+
for entry: Dictionary in holder.get_property_list():
|
|
493
|
+
if str(entry["name"]) == named:
|
|
494
|
+
return true
|
|
495
|
+
return false
|
|
496
|
+
|
|
497
|
+
|
|
416
498
|
func set_property(params: Dictionary) -> Dictionary:
|
|
417
499
|
var node_path: String = str(params.get("path", ""))
|
|
418
500
|
var property: String = str(params.get("property", ""))
|
|
@@ -421,19 +503,29 @@ func set_property(params: Dictionary) -> Dictionary:
|
|
|
421
503
|
if node_path.is_empty() or property.is_empty():
|
|
422
504
|
return {"type": "error", "message": "Node path and property required"}
|
|
423
505
|
|
|
424
|
-
var
|
|
425
|
-
if
|
|
426
|
-
return
|
|
506
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
507
|
+
if standing.has("message"):
|
|
508
|
+
return standing
|
|
509
|
+
var node: Node = standing["node"]
|
|
427
510
|
|
|
428
|
-
|
|
429
|
-
|
|
511
|
+
# Through a path as well, for the reason [method _reached] gives, and read back off the same
|
|
512
|
+
# holder afterwards: a set that does not take says so by answering with the old value, which is
|
|
513
|
+
# how a typed container refusing a write is told from one accepting it.
|
|
514
|
+
var reached: Dictionary = _reached(node, node_path, property)
|
|
515
|
+
if reached.has("message"):
|
|
516
|
+
return reached
|
|
517
|
+
|
|
518
|
+
var holder: Object = reached["holder"]
|
|
519
|
+
var named: String = reached["name"]
|
|
520
|
+
var old_value: Variant = holder.get(named)
|
|
521
|
+
holder.set(named, _values.fitted(value, typeof(old_value)))
|
|
430
522
|
|
|
431
523
|
return {
|
|
432
524
|
"type": "property_set",
|
|
433
525
|
"path": node_path,
|
|
434
526
|
"property": property,
|
|
435
527
|
"old_value": _values.serialize(old_value),
|
|
436
|
-
"new_value": _values.serialize(
|
|
528
|
+
"new_value": _values.serialize(holder.get(named))
|
|
437
529
|
}
|
|
438
530
|
|
|
439
531
|
|
|
@@ -445,18 +537,28 @@ func call_method(params: Dictionary) -> Dictionary:
|
|
|
445
537
|
if node_path.is_empty() or method.is_empty():
|
|
446
538
|
return {"type": "error", "message": "Node path and method required"}
|
|
447
539
|
|
|
448
|
-
var
|
|
449
|
-
if
|
|
450
|
-
return
|
|
540
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
541
|
+
if standing.has("message"):
|
|
542
|
+
return standing
|
|
543
|
+
var node: Node = standing["node"]
|
|
544
|
+
|
|
545
|
+
# Through a path as well, for the reason [method _reached] gives. What a game does hangs off its
|
|
546
|
+
# nodes as much as its state does, so reading `_game:run:day` while being unable to call
|
|
547
|
+
# `_game:run:advance` answers half of what a node holds and refuses the other half.
|
|
548
|
+
var reached: Dictionary = _reached(node, node_path, method)
|
|
549
|
+
if reached.has("message"):
|
|
550
|
+
return reached
|
|
451
551
|
|
|
452
|
-
|
|
453
|
-
|
|
552
|
+
var holder: Object = reached["holder"]
|
|
553
|
+
var named: String = reached["name"]
|
|
554
|
+
if not holder.has_method(named):
|
|
555
|
+
return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
|
|
454
556
|
|
|
455
557
|
var deserialized_args: Array = []
|
|
456
558
|
for index: int in args.size():
|
|
457
|
-
deserialized_args.append(_values.fitted(args[index], _values.parameter_type(
|
|
559
|
+
deserialized_args.append(_values.fitted(args[index], _values.parameter_type(holder, named, index)))
|
|
458
560
|
|
|
459
|
-
var result: Variant =
|
|
561
|
+
var result: Variant = holder.callv(named, deserialized_args)
|
|
460
562
|
|
|
461
563
|
return {"type": "method_result", "path": node_path, "method": method, "result": _values.serialize(result)}
|
|
462
564
|
|
|
@@ -42,6 +42,37 @@ func serialize(value: Variant) -> Variant:
|
|
|
42
42
|
return call(serialiser, value) if not serialiser.is_empty() else value
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
## The node [param path] names under [param root], or a refusal saying why there is not one.
|
|
46
|
+
##
|
|
47
|
+
## The other direction of what a node serialises as, and here for that reason: a node crosses the
|
|
48
|
+
## wire as its path, so a path crossing back is a node or it is nothing. One answer for every op,
|
|
49
|
+
## because each of them read a path for itself and they could disagree about what one means.
|
|
50
|
+
##
|
|
51
|
+
## A colon is the disagreement worth its own sentence. Godot reads everything after the first one
|
|
52
|
+
## as subnames and [method Node.get_node_or_null] drops them, so "/root/Main:_game:run" was quietly
|
|
53
|
+
## answered about "/root/Main", and an op that then said the path had no method `advance` was right
|
|
54
|
+
## about an object nobody had asked for. Colons reach through what a node holds, and the arguments
|
|
55
|
+
## that take them are the property and the method.
|
|
56
|
+
static func node_at(root: Node, path: String) -> Dictionary:
|
|
57
|
+
if path.is_empty():
|
|
58
|
+
return {"type": "error", "message": "Node path required"}
|
|
59
|
+
|
|
60
|
+
if path.contains(":"):
|
|
61
|
+
var shape: String = (
|
|
62
|
+
'"%s" names a node and colons reach past one: "%s" is the node,'
|
|
63
|
+
+ ' and "%s" goes in the property or the method'
|
|
64
|
+
)
|
|
65
|
+
return {
|
|
66
|
+
"type": "error",
|
|
67
|
+
"message": shape % [path, path.get_slice(":", 0), path.substr(path.find(":") + 1)],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var node: Node = root.get_node_or_null(path)
|
|
71
|
+
if node == null:
|
|
72
|
+
return {"type": "error", "message": "Node not found: " + path}
|
|
73
|
+
return {"node": node}
|
|
74
|
+
|
|
75
|
+
|
|
45
76
|
func _serialize_nil(_value: Variant) -> Variant:
|
|
46
77
|
return null
|
|
47
78
|
|
|
@@ -53,9 +53,10 @@ func wait_signal(params: Dictionary) -> Dictionary:
|
|
|
53
53
|
if node_path.is_empty() or signal_name.is_empty():
|
|
54
54
|
return {"type": "error", "message": "Node path and signal name required"}
|
|
55
55
|
|
|
56
|
-
var
|
|
57
|
-
if
|
|
58
|
-
return
|
|
56
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
57
|
+
if standing.has("message"):
|
|
58
|
+
return standing
|
|
59
|
+
var node: Node = standing["node"]
|
|
59
60
|
if not node.has_signal(signal_name):
|
|
60
61
|
return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
|
|
61
62
|
|
|
@@ -90,8 +91,9 @@ func wait_until(params: Dictionary) -> Dictionary:
|
|
|
90
91
|
return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
|
|
91
92
|
if node_path.is_empty():
|
|
92
93
|
return {"type": "error", "message": "Node path required"}
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
95
|
+
if standing.has("message"):
|
|
96
|
+
return standing
|
|
95
97
|
if not says.is_empty():
|
|
96
98
|
return await _wait_until_said(node_path, says, timeout_ms)
|
|
97
99
|
if property.is_empty():
|
|
@@ -99,7 +101,7 @@ func wait_until(params: Dictionary) -> Dictionary:
|
|
|
99
101
|
if not params.has("value"):
|
|
100
102
|
return {"type": "error", "message": "A value to wait for is required"}
|
|
101
103
|
|
|
102
|
-
var node: Node =
|
|
104
|
+
var node: Node = standing["node"]
|
|
103
105
|
|
|
104
106
|
var current: Variant = node.get(property)
|
|
105
107
|
var wanted: Variant = _values.fitted(params["value"], typeof(current))
|