gdharness 1.1.45 → 1.1.47
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 +217 -51
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +97 -3
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +13 -2
- package/build/godot/addons/gdharness_runtime/runtime_menus.gd +1 -1
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +160 -41
- package/build/godot/addons/gdharness_runtime/runtime_says.gd +26 -2
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +183 -12
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +14 -3
- package/build/index.js +218 -58
- package/package.json +1 -1
|
@@ -41,26 +41,38 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
41
41
|
# The words of the control that matched, which a button pressed on a label's behalf has none of.
|
|
42
42
|
var said: Array[String] = []
|
|
43
43
|
var exact: Array[bool] = []
|
|
44
|
+
# Whether the words are only inside longer words there, "out" in "Scout".
|
|
45
|
+
var inside: Array[bool] = []
|
|
44
46
|
var hidden: int = 0
|
|
47
|
+
# Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
|
|
48
|
+
# that only says them as part of more stands in for.
|
|
49
|
+
var exact_out_of_reach: Array[Node] = []
|
|
50
|
+
var out_of_reach_as: Array[String] = []
|
|
45
51
|
var pending: Array[Node] = [reached["node"]]
|
|
46
52
|
while not pending.is_empty():
|
|
47
53
|
var node: Node = pending.pop_front()
|
|
48
54
|
if node is Control and Says.says(node, wanted):
|
|
49
55
|
if not Queries.shown(node):
|
|
50
56
|
hidden += 1
|
|
57
|
+
if _says_exactly(Words.said_by(node), wanted):
|
|
58
|
+
exact_out_of_reach.append(node)
|
|
59
|
+
out_of_reach_as.append("hidden")
|
|
51
60
|
else:
|
|
52
61
|
var control: Control = node
|
|
53
62
|
var target: Control = _pressed_through(control)
|
|
54
63
|
var words: String = Words.said_by(node)
|
|
55
64
|
var whole: bool = _says_exactly(words, wanted)
|
|
65
|
+
var buried: bool = not whole and _inside_a_word(words, wanted)
|
|
56
66
|
var at: int = matched.find(target)
|
|
57
67
|
if at == -1:
|
|
58
68
|
matched.append(target)
|
|
59
69
|
said.append(words)
|
|
60
70
|
exact.append(whole)
|
|
61
|
-
|
|
71
|
+
inside.append(buried)
|
|
72
|
+
elif (whole and not exact[at]) or (inside[at] and not buried):
|
|
62
73
|
said[at] = words
|
|
63
|
-
exact[at] =
|
|
74
|
+
exact[at] = whole
|
|
75
|
+
inside[at] = buried
|
|
64
76
|
var children: Array[Node] = node.get_children(true)
|
|
65
77
|
for index: int in range(children.size() - 1, -1, -1):
|
|
66
78
|
pending.push_front(children[index])
|
|
@@ -74,17 +86,27 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
74
86
|
for index: int in matched.size():
|
|
75
87
|
if _off_screen(matched[index]):
|
|
76
88
|
outside += 1
|
|
89
|
+
if exact[index]:
|
|
90
|
+
exact_out_of_reach.append(matched[index])
|
|
91
|
+
out_of_reach_as.append("outside what the screen shows")
|
|
77
92
|
continue
|
|
78
93
|
var over: Node = _cover_of(matched[index], drawn, areas)
|
|
79
94
|
if over == null:
|
|
80
95
|
open.append(index)
|
|
81
96
|
else:
|
|
82
97
|
covered += 1
|
|
98
|
+
if exact[index]:
|
|
99
|
+
exact_out_of_reach.append(matched[index])
|
|
100
|
+
out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
|
|
83
101
|
if cover == null:
|
|
84
102
|
cover = over
|
|
85
|
-
# Stable, so matches of one rank keep the order they are drawn in.
|
|
103
|
+
# Stable, so matches of one rank keep the order they are drawn in. Words only inside longer words
|
|
104
|
+
# come after everything else: ranked as a button saying them as part of more, "Scout, 90" was
|
|
105
|
+
# pressed for "Out" over a control saying exactly "Out", and spent a game's silver.
|
|
86
106
|
open.sort_custom(
|
|
87
107
|
func(a: int, b: int) -> bool:
|
|
108
|
+
if inside[a] != inside[b]:
|
|
109
|
+
return inside[b]
|
|
88
110
|
var first: int = _rank(matched[a], exact[a])
|
|
89
111
|
var second: int = _rank(matched[b], exact[b])
|
|
90
112
|
return first > second or (first == second and a < b)
|
|
@@ -92,10 +114,14 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
92
114
|
var targets: Array[Control] = []
|
|
93
115
|
var ranks: Array[int] = []
|
|
94
116
|
var lines: Array[String] = []
|
|
117
|
+
var wholes: Array[bool] = []
|
|
118
|
+
var insides: Array[bool] = []
|
|
95
119
|
for index: int in open:
|
|
96
120
|
targets.append(matched[index])
|
|
97
121
|
ranks.append(_rank(matched[index], exact[index]))
|
|
98
122
|
lines.append(said[index])
|
|
123
|
+
wholes.append(exact[index])
|
|
124
|
+
insides.append(inside[index])
|
|
99
125
|
|
|
100
126
|
var under: String = "" if root_path == "/root" else " under %s" % root_path
|
|
101
127
|
if targets.is_empty():
|
|
@@ -103,11 +129,21 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
103
129
|
"type": "error",
|
|
104
130
|
"message":
|
|
105
131
|
(
|
|
106
|
-
'no control on screen%s says "%s", so nothing was clicked%s%s%s'
|
|
107
|
-
% [
|
|
132
|
+
'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
|
|
133
|
+
% [
|
|
134
|
+
under,
|
|
135
|
+
wanted,
|
|
136
|
+
_hidden_note(hidden),
|
|
137
|
+
_outside_note(outside),
|
|
138
|
+
_covered_note(covered, cover),
|
|
139
|
+
_open_menu_note(root, wanted),
|
|
140
|
+
]
|
|
108
141
|
)
|
|
109
142
|
}
|
|
110
|
-
var best: int =
|
|
143
|
+
var best: int = 0
|
|
144
|
+
for at: int in targets.size():
|
|
145
|
+
if ranks[at] == ranks[0] and insides[at] == insides[0]:
|
|
146
|
+
best += 1
|
|
111
147
|
if which == null and best > 1:
|
|
112
148
|
var order: String = "" if best == targets.size() else ", best matches first"
|
|
113
149
|
return {
|
|
@@ -129,7 +165,77 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
129
165
|
% [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
|
|
130
166
|
)
|
|
131
167
|
}
|
|
168
|
+
# A match saying the words as part of more, while another control says exactly them, is a stand-in
|
|
169
|
+
# for that one, not the control meant: a tab reading "Out" sat in a collapsed drawer and a dispatch
|
|
170
|
+
# saying somebody "fell out in the hall" was pressed, and with the tab's own words on screen in a
|
|
171
|
+
# control that is not a button, a card reading "Scout, 90" was. Pressed only when asked for by
|
|
172
|
+
# index, which is the caller saying the match is what they meant.
|
|
173
|
+
var exact_elsewhere: Array[Node] = exact_out_of_reach.duplicate()
|
|
174
|
+
var elsewhere_as: Array[String] = out_of_reach_as.duplicate()
|
|
175
|
+
# On screen only when it takes clicks itself: a heading saying exactly "Onward" beside a button
|
|
176
|
+
# saying "Onward now" is not what a click on "Onward" is for, and the button is pressed.
|
|
177
|
+
for other: int in targets.size():
|
|
178
|
+
var taking: bool = targets[other].get_mouse_filter_with_override() != Control.MOUSE_FILTER_IGNORE
|
|
179
|
+
if other != index and wholes[other] and taking:
|
|
180
|
+
exact_elsewhere.append(targets[other])
|
|
181
|
+
elsewhere_as.append("on screen, taking clicks but not an enabled button")
|
|
182
|
+
var best_one: String = "the one on screen," if targets.size() == 1 else "the best match on screen,"
|
|
183
|
+
if which == null and not wholes[index] and not exact_elsewhere.is_empty():
|
|
184
|
+
return {
|
|
185
|
+
"type": "error",
|
|
186
|
+
"message":
|
|
187
|
+
(
|
|
188
|
+
(
|
|
189
|
+
'no %s on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
|
|
190
|
+
+ ' "%s", while %s; pass index %d to press that one anyway'
|
|
191
|
+
)
|
|
192
|
+
% [
|
|
193
|
+
"button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
|
|
194
|
+
under,
|
|
195
|
+
wanted,
|
|
196
|
+
best_one,
|
|
197
|
+
targets[index].get_path(),
|
|
198
|
+
_shortened(lines[index]),
|
|
199
|
+
_out_of_reach_exactly(exact_elsewhere, elsewhere_as),
|
|
200
|
+
index,
|
|
201
|
+
]
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
if which == null and insides[index]:
|
|
205
|
+
return {
|
|
206
|
+
"type": "error",
|
|
207
|
+
"message":
|
|
208
|
+
(
|
|
209
|
+
(
|
|
210
|
+
'no control on screen%s says "%s" as a word of its own, so nothing was clicked: %s %s has'
|
|
211
|
+
+ ' it only inside a longer word, "%s"; pass index %d to press that one anyway'
|
|
212
|
+
)
|
|
213
|
+
% [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
# Words in a sentence on a control that lets the pointer through: the press lands on whatever is
|
|
217
|
+
# under it, which is nothing the words name. A line saying what "is out there" was the best match
|
|
218
|
+
# left for "Out" once the cards saying "Scout" were passed over.
|
|
219
|
+
if (
|
|
220
|
+
which == null
|
|
221
|
+
and not wholes[index]
|
|
222
|
+
and targets[index].get_mouse_filter_with_override() == Control.MOUSE_FILTER_IGNORE
|
|
223
|
+
):
|
|
224
|
+
return {
|
|
225
|
+
"type": "error",
|
|
226
|
+
"message":
|
|
227
|
+
(
|
|
228
|
+
(
|
|
229
|
+
'no control on screen%s that takes a click says "%s", so nothing was clicked: %s %s says it'
|
|
230
|
+
+ ' as part of "%s" and lets the pointer through to what is under it; pass index %d to'
|
|
231
|
+
+ " click there anyway"
|
|
232
|
+
)
|
|
233
|
+
% [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
|
|
234
|
+
)
|
|
235
|
+
}
|
|
132
236
|
var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
|
|
237
|
+
if not wholes[index]:
|
|
238
|
+
found["partOf"] = _shortened(lines[index])
|
|
133
239
|
if which == null and targets.size() > 1:
|
|
134
240
|
var others: String = (
|
|
135
241
|
"the other one on screen %s" % _passed_over(ranks[0], true)
|
|
@@ -157,7 +263,8 @@ static func _rank(target: Control, whole: bool) -> int:
|
|
|
157
263
|
## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
|
|
158
264
|
## named WARD, and read as its words run together it said WARD only as part of more, level with
|
|
159
265
|
## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
|
|
160
|
-
static func _says_exactly(
|
|
266
|
+
static func _says_exactly(written: String, wanted: String) -> bool:
|
|
267
|
+
var said: String = Says.spaced(written)
|
|
161
268
|
for words: String in Says.alternatives(wanted):
|
|
162
269
|
if not Says.is_plain(words):
|
|
163
270
|
if said.matchn(words):
|
|
@@ -172,6 +279,32 @@ static func _says_exactly(said: String, wanted: String) -> bool:
|
|
|
172
279
|
return false
|
|
173
280
|
|
|
174
281
|
|
|
282
|
+
## Whether [param written] has the plain words [param wanted] asks for only inside longer words,
|
|
283
|
+
## never starting where a word starts: "out" in "Scout". Words starting a longer one count as said,
|
|
284
|
+
## "Recruit" in "Recruits", and so does anything matched by a pattern.
|
|
285
|
+
static func _inside_a_word(written: String, wanted: String) -> bool:
|
|
286
|
+
var said: String = Says.spaced(written).to_lower()
|
|
287
|
+
var found: bool = false
|
|
288
|
+
for words: String in Says.alternatives(wanted):
|
|
289
|
+
var looked_for: String = words.to_lower()
|
|
290
|
+
if not Says.is_plain(words) or not _in_a_word(looked_for.unicode_at(0)):
|
|
291
|
+
return false
|
|
292
|
+
var at: int = said.find(looked_for)
|
|
293
|
+
while at >= 0:
|
|
294
|
+
found = true
|
|
295
|
+
if at == 0 or not _in_a_word(said.unicode_at(at - 1)):
|
|
296
|
+
return false
|
|
297
|
+
at = said.find(looked_for, at + 1)
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
## Whether the character [param code] is part of a word: a letter, a digit or an underscore. A letter
|
|
302
|
+
## is told by having a case, which leaves scripts without one counted as breaks between words.
|
|
303
|
+
static func _in_a_word(code: int) -> bool:
|
|
304
|
+
var character: String = String.chr(code)
|
|
305
|
+
return character.to_lower() != character.to_upper() or character.is_valid_int() or character == "_"
|
|
306
|
+
|
|
307
|
+
|
|
175
308
|
## [param count] matches of [param rank], as the start of a sentence.
|
|
176
309
|
static func _described(count: int, rank: int, wanted: String, under: String) -> String:
|
|
177
310
|
var what: String = "button" if rank >= 2 else "control"
|
|
@@ -461,7 +594,7 @@ static func _clipped_away(
|
|
|
461
594
|
## words. A line in another font or holding an image measures off from what is drawn, which a wide
|
|
462
595
|
## line with nothing between the words and its start hardly does.
|
|
463
596
|
static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
|
|
464
|
-
var text: String = Words.said_by(label)
|
|
597
|
+
var text: String = Says.spaced(Words.said_by(label))
|
|
465
598
|
var start: int = -1
|
|
466
599
|
var length: int = 0
|
|
467
600
|
for words: String in Says.alternatives(wanted):
|
|
@@ -515,10 +648,48 @@ static func _layer_of(control: Control) -> int:
|
|
|
515
648
|
static func _candidates(targets: Array[Control], said: Array[String]) -> String:
|
|
516
649
|
var named: Array[String] = []
|
|
517
650
|
for index: int in mini(targets.size(), LISTED_CANDIDATES):
|
|
518
|
-
|
|
519
|
-
if words.length() > 60:
|
|
520
|
-
words = words.substr(0, 57) + "..."
|
|
521
|
-
named.append('%d %s ("%s")' % [index, targets[index].get_path(), words])
|
|
651
|
+
named.append('%d %s ("%s")' % [index, targets[index].get_path(), _shortened(said[index])])
|
|
522
652
|
if targets.size() > LISTED_CANDIDATES:
|
|
523
653
|
named.append("and %d more" % (targets.size() - LISTED_CANDIDATES))
|
|
524
654
|
return "; ".join(named)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
## What to add to a refusal that found nothing on screen, when an open menu has an item saying the
|
|
658
|
+
## words: a menu draws its items rather than holding a control for each, so a click finds none, while
|
|
659
|
+
## a wait for the words finds the item. A wait met on "Leave the hall" was followed by a click on it
|
|
660
|
+
## refused as nothing on screen saying it, with no word of the call that chooses it.
|
|
661
|
+
static func _open_menu_note(root: Node, wanted: String) -> String:
|
|
662
|
+
var pending: Array[Node] = [root]
|
|
663
|
+
while not pending.is_empty():
|
|
664
|
+
var node: Node = pending.pop_back()
|
|
665
|
+
var menu: PopupMenu = node as PopupMenu
|
|
666
|
+
if menu != null and menu.visible:
|
|
667
|
+
for item: int in menu.item_count:
|
|
668
|
+
var text: String = menu.get_item_text(item)
|
|
669
|
+
if Says.matches(text, wanted):
|
|
670
|
+
var owner: Node = menu.get_parent()
|
|
671
|
+
var named: Node = owner if owner is MenuButton or owner is OptionButton else menu
|
|
672
|
+
return (
|
|
673
|
+
(
|
|
674
|
+
'; the open menu %s has an item saying it, "%s", which a click cannot reach:'
|
|
675
|
+
+ ' choose it with runtime_input choose, path %s and text "%s"'
|
|
676
|
+
)
|
|
677
|
+
% [menu.get_path(), text, named.get_path(), text]
|
|
678
|
+
)
|
|
679
|
+
pending.append_array(node.get_children(true))
|
|
680
|
+
return ""
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
## [param words] on one line and at most sixty characters, for naming a control by what it says.
|
|
684
|
+
static func _shortened(words: String) -> String:
|
|
685
|
+
var flat: String = words.replace("\n", " ").strip_edges()
|
|
686
|
+
return flat if flat.length() <= 60 else flat.substr(0, 57) + "..."
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
## The controls in [param nodes], each saying the words exactly and out of a click's reach for the
|
|
690
|
+
## reason [param why] holds at the same place, as the end of a sentence.
|
|
691
|
+
static func _out_of_reach_exactly(nodes: Array[Node], why: Array[String]) -> String:
|
|
692
|
+
var first: String = "%s, %s" % [nodes[0].get_path(), why[0]]
|
|
693
|
+
if nodes.size() == 1:
|
|
694
|
+
return "1 control says exactly that and cannot be clicked: %s" % first
|
|
695
|
+
return "%d controls say exactly that and cannot be clicked, such as %s" % [nodes.size(), first]
|
|
@@ -25,7 +25,14 @@ static func said_by(node: Node) -> String:
|
|
|
25
25
|
return _label_says(label)
|
|
26
26
|
if _draws_a_list(node):
|
|
27
27
|
return "\n".join(lines_said_by(node))
|
|
28
|
-
|
|
28
|
+
var own: String = _text_of(node)
|
|
29
|
+
# A control showing a picture rather than words is named for a player who cannot see it, and that
|
|
30
|
+
# name is the one the words a caller asks by are meant for: the rail's "Out" tab is a square with
|
|
31
|
+
# an icon on it, and a click on "Out" found only the words around it.
|
|
32
|
+
if own.is_empty() and node is Control:
|
|
33
|
+
var control: Control = node
|
|
34
|
+
return control.atr(control.accessibility_name).strip_edges()
|
|
35
|
+
return own
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
|
|
@@ -139,12 +146,16 @@ static func _text_of(node: Node) -> String:
|
|
|
139
146
|
if not text is String:
|
|
140
147
|
return ""
|
|
141
148
|
var words: String = text
|
|
142
|
-
# What somebody typed is drawn as typed, never translated.
|
|
149
|
+
# What somebody typed is drawn as typed, never translated. An empty field draws its placeholder
|
|
150
|
+
# instead, which is what a player reads it by: a search field was found by nothing it showed.
|
|
143
151
|
if node is LineEdit:
|
|
144
152
|
var field: LineEdit = node
|
|
153
|
+
if words.is_empty():
|
|
154
|
+
return field.atr(field.placeholder_text).strip_edges()
|
|
145
155
|
return masked(field) if field.secret else words.strip_edges()
|
|
146
156
|
if node is TextEdit:
|
|
147
|
-
|
|
157
|
+
var area: TextEdit = node
|
|
158
|
+
return (area.atr(area.placeholder_text) if words.is_empty() else words).strip_edges()
|
|
148
159
|
words = node.atr(words)
|
|
149
160
|
if node is Label3D:
|
|
150
161
|
var sign_text: Label3D = node
|