gdharness 1.3.21 → 1.3.23

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.
@@ -13,6 +13,7 @@ extends RefCounted
13
13
  const GdhRead = preload("reading.gd")
14
14
  const GdhSame = preload("same_values.gd")
15
15
  const GdhSerialisation = preload("serialisation.gd")
16
+ const GdhShapes = preload("value_shapes.gd")
16
17
 
17
18
 
18
19
  ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
@@ -515,18 +516,24 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
515
516
  scripted[str(declared.get("name", ""))] = true
516
517
  var serialiser: GdhSerialisation = GdhSerialisation.new()
517
518
  var properties: Dictionary = {}
518
- for p: Dictionary in loaded.get_property_list():
519
+ # A Curve's points are read off one with none, since the engine has no default for point_0.
520
+ var fresh: Object = (
521
+ ClassDB.instantiate(loaded.get_class()) if GdhShapes.STORED_APART.has(loaded.get_class()) else null
522
+ )
523
+ for p: Dictionary in GdhShapes.read_properties(loaded):
519
524
  var property: String = str(p.get("name", ""))
520
525
  if property.is_empty() or property == "script":
521
526
  continue
522
- if not (GdhRead.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
523
- continue
524
527
  var value: Variant = loaded.get(property)
525
528
  if not include_defaults:
526
529
  var default: Variant = (
527
530
  script.get_property_default_value(property)
528
531
  if scripted.has(property)
529
- else ClassDB.class_get_property_default_value(loaded.get_class(), property)
532
+ else (
533
+ fresh.get(property)
534
+ if fresh != null
535
+ else ClassDB.class_get_property_default_value(loaded.get_class(), property)
536
+ )
530
537
  )
531
538
  if GdhSame.same(value, default):
532
539
  continue
@@ -698,14 +698,12 @@ func _describe_built_in(resource: Resource) -> Dictionary:
698
698
  else:
699
699
  described["_type"] = str(script.get_global_name())
700
700
  var fresh: Object = _fresh(script)
701
- for property: Dictionary in resource.get_property_list():
701
+ if fresh == null and GdhShapes.STORED_APART.has(resource.get_class()):
702
+ fresh = ClassDB.instantiate(resource.get_class())
703
+ for property: Dictionary in GdhShapes.read_properties(resource):
702
704
  var usage: int = property.get("usage", 0)
703
705
  var name: String = str(property.get("name", ""))
704
- if (
705
- not (usage & PROPERTY_USAGE_STORAGE)
706
- or name == "script"
707
- or (name.begins_with("resource_") and name != "resource_name")
708
- ):
706
+ if name == "script" or (name.begins_with("resource_") and name != "resource_name"):
709
707
  continue
710
708
  var held: Variant = resource.get(name)
711
709
  var default: Variant = ClassDB.class_get_property_default_value(resource.get_class(), name)
@@ -58,7 +58,8 @@ func add_animation_track(args: Dictionary) -> Dictionary:
58
58
  )
59
59
  )
60
60
  var keyframes: Array = keyed[1]
61
- var built: Array = _add_track(anim, track, target, target_path, keyframes)
61
+ var replacing: bool = GdhRead.as_bool(args.get("replaceKeys", false))
62
+ var built: Array = _add_track(anim, track, target, target_path, keyframes, replacing)
62
63
  if not str(built[1]).is_empty():
63
64
  return scene.refuse(str(built[1]))
64
65
  var track_idx: int = built[0]
@@ -82,6 +83,10 @@ func add_animation_track(args: Dictionary) -> Dictionary:
82
83
  }
83
84
  if built[3]:
84
85
  answer["existingTrack"] = true
86
+ var replaced: Array = built[5]
87
+ if not replaced.is_empty():
88
+ answer["keysReplaced"] = replaced
89
+ answer["keysAdded"] = keyframes.size() - replaced.size()
85
90
  if built[2] != null:
86
91
  answer["methodFound"] = built[2]
87
92
  # Written all the same, as a missing method is: the method may be changed next.
@@ -106,15 +111,16 @@ func add_animation_track(args: Dictionary) -> Dictionary:
106
111
 
107
112
 
108
113
  ## Adds [param track] to [param anim] with [param keyframes], as [index, why not, methodFound,
109
- ## existing, misfit]: methodFound is null for a property track, existing is whether the keys went
110
- ## into a track the animation already had for that path, and misfit why a method key's arguments
111
- ## cannot reach the method, or "".
114
+ ## existing, misfit, replaced]: methodFound is null for a property track, existing is whether the
115
+ ## keys went into a track the animation already had for that path, misfit why a method key's
116
+ ## arguments cannot reach the method, or "", and replaced each key [param replacing] let a keyframe
117
+ ## take the place of, as its time and what it held.
112
118
  ##
113
119
  ## Into that track rather than a second one beside it, as the editor keys a property it already
114
120
  ## animates: two tracks for one path both play, the later overriding the earlier, and a key meant
115
121
  ## to extend the motion was split off where reading the animation showed two halves.
116
122
  func _add_track(
117
- anim: Animation, track: Dictionary, target: Node, target_path: String, keyframes: Array
123
+ anim: Animation, track: Dictionary, target: Node, target_path: String, keyframes: Array, replacing: bool
118
124
  ) -> Array:
119
125
  var track_type: String = str(track.get("type", ""))
120
126
  var keys: Array = []
@@ -125,21 +131,21 @@ func _add_track(
125
131
  if track_type == "property":
126
132
  var prop_name: String = str(track.get("property", ""))
127
133
  if prop_name.is_empty():
128
- return [-1, "track.property is required for property track", null, false, ""]
134
+ return _not_added("track.property is required for property track")
129
135
  var values: Array = _key_values(target, prop_name, keyframes)
130
136
  if not values[0]:
131
- return [-1, values[1], null, false, ""]
137
+ return _not_added(str(values[1]))
132
138
  keys = values[1]
133
139
  path = NodePath(target_path + ":" + prop_name)
134
140
  elif track_type == "method":
135
141
  var method_name: String = str(track.get("method", ""))
136
142
  if method_name.is_empty():
137
- return [-1, "track.method is required for method track", null, false, ""]
143
+ return _not_added("track.method is required for method track")
138
144
  for index: int in keyframes.size():
139
145
  var keyframe: Dictionary = keyframes[index]
140
146
  var given: Variant = keyframe.get("args", [])
141
147
  if not given is Array:
142
- return [-1, "keyframes[%d].args must be a list." % index, null, false, ""]
148
+ return _not_added("keyframes[%d].args must be a list." % index)
143
149
  var raw: Array = given
144
150
  var arguments: Array = _fitted(target, method_name, _parse_method_args(raw))
145
151
  keys.append({"method": method_name, "args": arguments})
@@ -148,47 +154,60 @@ func _add_track(
148
154
  method_found = target.has_method(method_name)
149
155
  kind = Animation.TYPE_METHOD
150
156
  else:
151
- return [
152
- -1,
157
+ return _not_added(
153
158
  (
154
159
  "add_track makes property and method tracks, and track.type is %s; remove_track takes any."
155
160
  % track_type
156
- ),
157
- null,
158
- false,
159
- ""
160
- ]
161
+ )
162
+ )
161
163
 
162
164
  var track_idx: int = anim.find_track(path, kind)
163
165
  var existing: bool = track_idx >= 0
166
+ var replaced: Array[Dictionary] = []
164
167
  if existing:
165
168
  # Checked before any key goes in, since track_insert_key replaces a key at the same time
166
169
  # without a word, and a refusal partway would leave the editor's scene half changed.
170
+ var values: GdhSerialisation = GdhSerialisation.new()
167
171
  for index: int in keyframes.size():
168
172
  var keyframe: Dictionary = keyframes[index]
169
173
  var time: float = GdhRead.as_float(keyframe["time"])
170
- if anim.track_find_key(track_idx, time, Animation.FIND_MODE_APPROX) >= 0:
171
- return [
172
- -1,
174
+ var key: int = anim.track_find_key(track_idx, time, Animation.FIND_MODE_APPROX)
175
+ if key < 0:
176
+ continue
177
+ if not replacing:
178
+ return _not_added(
173
179
  (
174
180
  (
175
181
  "Track %d already animates %s and has a key at %s s, which keyframes[%d] would "
176
- + "replace. Nothing was written."
182
+ + "replace. Nothing was written; replaceKeys true replaces it."
177
183
  )
178
184
  % [track_idx, str(path), _seconds(time), index]
179
- ),
180
- null,
181
- false,
182
- ""
183
- ]
185
+ )
186
+ )
187
+ # A method key's value is its call, {"method": ..., "args": [...]}. The time is as asked:
188
+ # the key's own, read back from a scene's 32-bit times, puts 0.1 at 0.100000001490116.
189
+ (
190
+ replaced
191
+ . append(
192
+ {
193
+ "time": time,
194
+ "was": values.serialize_value(anim.track_get_key_value(track_idx, key)),
195
+ }
196
+ )
197
+ )
184
198
  else:
185
199
  track_idx = anim.add_track(kind)
186
200
  anim.track_set_path(track_idx, path)
187
201
  for index: int in keyframes.size():
188
202
  var keyframe: Dictionary = keyframes[index]
189
203
  if anim.track_insert_key(track_idx, GdhRead.as_float(keyframe["time"]), keys[index]) < 0:
190
- return [-1, "Keyframe %d could not be inserted." % index, null, false, ""]
191
- return [track_idx, "", method_found, existing, misfit]
204
+ return _not_added("Keyframe %d could not be inserted." % index)
205
+ return [track_idx, "", method_found, existing, misfit, replaced]
206
+
207
+
208
+ ## What [method _add_track] answers when it added nothing, because of [param why].
209
+ static func _not_added(why: String) -> Array:
210
+ return [-1, why, null, false, "", []]
192
211
 
193
212
 
194
213
  ## [param arguments] as [param method] on [param target] takes them: a whole number read as a
@@ -7,6 +7,17 @@ extends RefCounted
7
7
 
8
8
  const GdhRead = preload("reading.gd")
9
9
 
10
+ ## The classes that store what the inspector shows, and a write takes, under other names: a Curve
11
+ ## keeps its points as _data, numbers and modes in one list, and its range as _limits, where the
12
+ ## inspector and resource_edit modify have point_count, point_0/position and min_value. A read
13
+ ## answering _data could not be written back the way it was read.
14
+ const STORED_APART: Dictionary[String, Array] = {
15
+ "Curve": ["_data", "_limits"],
16
+ "Curve2D": ["_data"],
17
+ "Curve3D": ["_data"],
18
+ }
19
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
20
+
10
21
  ## What each packed array holds, one value of which every element is fitted to.
11
22
  const PACKED_ELEMENTS: Dictionary = {
12
23
  TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
@@ -282,3 +293,24 @@ static func _unread(value: Variant, type: int) -> String:
282
293
  if left.has("type") and not fields.has("_type"):
283
294
  return note + ': a value names its type under "_type".'
284
295
  return note + "."
296
+
297
+
298
+ ## The properties of [param object] a read answers with, as [method Object.get_property_list] gives
299
+ ## them: those it stores, with the inspector's own in place of what [constant STORED_APART] names.
300
+ static func read_properties(object: Object) -> Array[Dictionary]:
301
+ var apart: Array = STORED_APART.get(object.get_class(), [])
302
+ var read: Array[Dictionary] = []
303
+ for property: Dictionary in object.get_property_list():
304
+ var usage: int = GdhRead.as_int(property.get("usage", 0))
305
+ var name: String = str(property.get("name", ""))
306
+ if usage & PROPERTY_USAGE_STORAGE:
307
+ if not apart.has(name):
308
+ read.append(property)
309
+ elif (
310
+ not apart.is_empty()
311
+ and usage & PROPERTY_USAGE_EDITOR
312
+ and not usage & HEADINGS
313
+ and name != "resource_path"
314
+ ):
315
+ read.append(property)
316
+ return read
@@ -698,14 +698,12 @@ func _describe_built_in(resource: Resource) -> Dictionary:
698
698
  else:
699
699
  described["_type"] = str(script.get_global_name())
700
700
  var fresh: Object = _fresh(script)
701
- for property: Dictionary in resource.get_property_list():
701
+ if fresh == null and GdhShapes.STORED_APART.has(resource.get_class()):
702
+ fresh = ClassDB.instantiate(resource.get_class())
703
+ for property: Dictionary in GdhShapes.read_properties(resource):
702
704
  var usage: int = property.get("usage", 0)
703
705
  var name: String = str(property.get("name", ""))
704
- if (
705
- not (usage & PROPERTY_USAGE_STORAGE)
706
- or name == "script"
707
- or (name.begins_with("resource_") and name != "resource_name")
708
- ):
706
+ if name == "script" or (name.begins_with("resource_") and name != "resource_name"):
709
707
  continue
710
708
  var held: Variant = resource.get(name)
711
709
  var default: Variant = ClassDB.class_get_property_default_value(resource.get_class(), name)
@@ -7,6 +7,17 @@ extends RefCounted
7
7
 
8
8
  const GdhRead = preload("reading.gd")
9
9
 
10
+ ## The classes that store what the inspector shows, and a write takes, under other names: a Curve
11
+ ## keeps its points as _data, numbers and modes in one list, and its range as _limits, where the
12
+ ## inspector and resource_edit modify have point_count, point_0/position and min_value. A read
13
+ ## answering _data could not be written back the way it was read.
14
+ const STORED_APART: Dictionary[String, Array] = {
15
+ "Curve": ["_data", "_limits"],
16
+ "Curve2D": ["_data"],
17
+ "Curve3D": ["_data"],
18
+ }
19
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
20
+
10
21
  ## What each packed array holds, one value of which every element is fitted to.
11
22
  const PACKED_ELEMENTS: Dictionary = {
12
23
  TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
@@ -282,3 +293,24 @@ static func _unread(value: Variant, type: int) -> String:
282
293
  if left.has("type") and not fields.has("_type"):
283
294
  return note + ': a value names its type under "_type".'
284
295
  return note + "."
296
+
297
+
298
+ ## The properties of [param object] a read answers with, as [method Object.get_property_list] gives
299
+ ## them: those it stores, with the inspector's own in place of what [constant STORED_APART] names.
300
+ static func read_properties(object: Object) -> Array[Dictionary]:
301
+ var apart: Array = STORED_APART.get(object.get_class(), [])
302
+ var read: Array[Dictionary] = []
303
+ for property: Dictionary in object.get_property_list():
304
+ var usage: int = GdhRead.as_int(property.get("usage", 0))
305
+ var name: String = str(property.get("name", ""))
306
+ if usage & PROPERTY_USAGE_STORAGE:
307
+ if not apart.has(name):
308
+ read.append(property)
309
+ elif (
310
+ not apart.is_empty()
311
+ and usage & PROPERTY_USAGE_EDITOR
312
+ and not usage & HEADINGS
313
+ and name != "resource_path"
314
+ ):
315
+ read.append(property)
316
+ return read
@@ -13,6 +13,7 @@ extends RefCounted
13
13
  const GdhRead = preload("reading.gd")
14
14
  const GdhSame = preload("same_values.gd")
15
15
  const GdhSerialisation = preload("serialisation.gd")
16
+ const GdhShapes = preload("value_shapes.gd")
16
17
 
17
18
 
18
19
  ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
@@ -515,18 +516,24 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
515
516
  scripted[str(declared.get("name", ""))] = true
516
517
  var serialiser: GdhSerialisation = GdhSerialisation.new()
517
518
  var properties: Dictionary = {}
518
- for p: Dictionary in loaded.get_property_list():
519
+ # A Curve's points are read off one with none, since the engine has no default for point_0.
520
+ var fresh: Object = (
521
+ ClassDB.instantiate(loaded.get_class()) if GdhShapes.STORED_APART.has(loaded.get_class()) else null
522
+ )
523
+ for p: Dictionary in GdhShapes.read_properties(loaded):
519
524
  var property: String = str(p.get("name", ""))
520
525
  if property.is_empty() or property == "script":
521
526
  continue
522
- if not (GdhRead.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
523
- continue
524
527
  var value: Variant = loaded.get(property)
525
528
  if not include_defaults:
526
529
  var default: Variant = (
527
530
  script.get_property_default_value(property)
528
531
  if scripted.has(property)
529
- else ClassDB.class_get_property_default_value(loaded.get_class(), property)
532
+ else (
533
+ fresh.get(property)
534
+ if fresh != null
535
+ else ClassDB.class_get_property_default_value(loaded.get_class(), property)
536
+ )
530
537
  )
531
538
  if GdhSame.same(value, default):
532
539
  continue
@@ -698,14 +698,12 @@ func _describe_built_in(resource: Resource) -> Dictionary:
698
698
  else:
699
699
  described["_type"] = str(script.get_global_name())
700
700
  var fresh: Object = _fresh(script)
701
- for property: Dictionary in resource.get_property_list():
701
+ if fresh == null and GdhShapes.STORED_APART.has(resource.get_class()):
702
+ fresh = ClassDB.instantiate(resource.get_class())
703
+ for property: Dictionary in GdhShapes.read_properties(resource):
702
704
  var usage: int = property.get("usage", 0)
703
705
  var name: String = str(property.get("name", ""))
704
- if (
705
- not (usage & PROPERTY_USAGE_STORAGE)
706
- or name == "script"
707
- or (name.begins_with("resource_") and name != "resource_name")
708
- ):
706
+ if name == "script" or (name.begins_with("resource_") and name != "resource_name"):
709
707
  continue
710
708
  var held: Variant = resource.get(name)
711
709
  var default: Variant = ClassDB.class_get_property_default_value(resource.get_class(), name)
@@ -7,6 +7,17 @@ extends RefCounted
7
7
 
8
8
  const GdhRead = preload("reading.gd")
9
9
 
10
+ ## The classes that store what the inspector shows, and a write takes, under other names: a Curve
11
+ ## keeps its points as _data, numbers and modes in one list, and its range as _limits, where the
12
+ ## inspector and resource_edit modify have point_count, point_0/position and min_value. A read
13
+ ## answering _data could not be written back the way it was read.
14
+ const STORED_APART: Dictionary[String, Array] = {
15
+ "Curve": ["_data", "_limits"],
16
+ "Curve2D": ["_data"],
17
+ "Curve3D": ["_data"],
18
+ }
19
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
20
+
10
21
  ## What each packed array holds, one value of which every element is fitted to.
11
22
  const PACKED_ELEMENTS: Dictionary = {
12
23
  TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
@@ -282,3 +293,24 @@ static func _unread(value: Variant, type: int) -> String:
282
293
  if left.has("type") and not fields.has("_type"):
283
294
  return note + ': a value names its type under "_type".'
284
295
  return note + "."
296
+
297
+
298
+ ## The properties of [param object] a read answers with, as [method Object.get_property_list] gives
299
+ ## them: those it stores, with the inspector's own in place of what [constant STORED_APART] names.
300
+ static func read_properties(object: Object) -> Array[Dictionary]:
301
+ var apart: Array = STORED_APART.get(object.get_class(), [])
302
+ var read: Array[Dictionary] = []
303
+ for property: Dictionary in object.get_property_list():
304
+ var usage: int = GdhRead.as_int(property.get("usage", 0))
305
+ var name: String = str(property.get("name", ""))
306
+ if usage & PROPERTY_USAGE_STORAGE:
307
+ if not apart.has(name):
308
+ read.append(property)
309
+ elif (
310
+ not apart.is_empty()
311
+ and usage & PROPERTY_USAGE_EDITOR
312
+ and not usage & HEADINGS
313
+ and name != "resource_path"
314
+ ):
315
+ read.append(property)
316
+ return read
@@ -25,6 +25,13 @@ const ROWS: String = "--rows-in"
25
25
  ## that is not parameterised. gdUnit4 prints nothing for a case it skips, and the caller needs every
26
26
  ## row an engine found to tell one that no engine ran.
27
27
  const LEFT_OUT: String = "gdharness left out"
28
+ ## Printed after a case or a suite's hooks that gdUnit4 says left orphan nodes, as
29
+ ## `res://suite.gd > test_case left 2: Node "Sandbox" of res://sandbox.gd, Timer "Timer"`, with
30
+ ## `after` for the suite's hooks. gdUnit4 names them only where a case calls
31
+ ## collect_orphan_node_details(), and otherwise counts them.
32
+ const ORPHANS_LEFT: String = "gdharness orphans:"
33
+ ## How many orphans one line names before it counts the rest.
34
+ const ORPHANS_NAMED: int = 8
28
35
  ## The flags turning off a report, with the gdUnit4 hook that writes it. A gate reads what a run
29
36
  ## prints, and each report finds every case it hears of in the session to write them all out as the
30
37
  ## run ends; project_test reads the JUnit one and turns off only the HTML one.
@@ -89,6 +96,17 @@ class GdhRunner:
89
96
  var cases_ended: int = 0
90
97
  ## The marker of the shutdown walk's oldest end, made as the run starts.
91
98
  var walk_scripts: Array[GDScript] = []
99
+ ## The orphan nodes as the running case and the running suite began, and those already named in
100
+ ## the suite, so each line names only what its own case or hooks left.
101
+ var orphans_at_case: Dictionary[int, bool] = {}
102
+ var orphans_at_suite: Dictionary[int, bool] = {}
103
+ var orphans_named: Dictionary[int, bool] = {}
104
+ ## The orphans new as the suite's first case began, which its before() made, and whether a case
105
+ ## has begun: gdUnit4 counts both hooks' orphans at the suite's end.
106
+ var orphans_from_before: Dictionary[int, bool] = {}
107
+ var case_began: bool = false
108
+ ## The suite running, which a case's own event does not carry.
109
+ var suite_running: String = ""
92
110
 
93
111
  ## As the run ends, the marker of the walk's newest end, and every script held past the walk, on
94
112
  ## an engine whose walk can stop part way ([GdhShutdownWalk]).
@@ -156,11 +174,85 @@ class GdhRunner:
156
174
  ## is announced: everything that reads the session, the hooks, the cases and the console printing
157
175
  ## every case, is handed it after this.
158
176
  func _on_gdunit_event(event: GdUnitEvent) -> void:
159
- if event.type() == GdUnitEvent.SESSION_START:
160
- _test_session = GdhIndexedSession.of(_test_session)
161
- elif event.type() == GdUnitEvent.TESTCASE_AFTER:
162
- cases_ended += 1
177
+ match event.type():
178
+ GdUnitEvent.SESSION_START:
179
+ _test_session = GdhIndexedSession.of(_test_session)
180
+ GdUnitEvent.TESTSUITE_BEFORE:
181
+ suite_running = event.resource_path()
182
+ orphans_at_suite = orphan_ids()
183
+ orphans_named.clear()
184
+ orphans_from_before.clear()
185
+ case_began = false
186
+ GdUnitEvent.TESTCASE_BEFORE:
187
+ orphans_at_case = orphan_ids()
188
+ if not case_began:
189
+ case_began = true
190
+ for id: int in new_orphans(orphans_at_suite):
191
+ orphans_from_before[id] = true
192
+ GdUnitEvent.TESTCASE_AFTER:
193
+ cases_ended += 1
163
194
  super(event)
195
+ # After gdUnit4's own report of the case, so the line falls under it.
196
+ if event.orphan_nodes() == 0:
197
+ return
198
+ if event.type() == GdUnitEvent.TESTCASE_AFTER:
199
+ say_left(event.test_name(), new_orphans(orphans_at_case))
200
+ elif event.type() == GdUnitEvent.TESTSUITE_AFTER:
201
+ var left: Array[int] = new_orphans(orphans_at_suite)
202
+ if not case_began:
203
+ say_left("before or after", left)
204
+ return
205
+ say_left("before", left.filter(func(id: int) -> bool: return orphans_from_before.has(id)))
206
+ say_left("after", left.filter(func(id: int) -> bool: return not orphans_from_before.has(id)))
207
+
208
+ ## The orphans now that were not there in [param before] and no line has named yet.
209
+ func new_orphans(before: Dictionary[int, bool]) -> Array[int]:
210
+ var found: Array[int] = []
211
+ for id: int in orphan_ids():
212
+ if not before.has(id) and not orphans_named.has(id):
213
+ found.append(id)
214
+ return found
215
+
216
+ ## Prints the orphans [param ids] as left by [param during], under [constant ORPHANS_LEFT], once
217
+ ## each in a suite.
218
+ func say_left(during: String, ids: Array[int]) -> void:
219
+ if ids.is_empty():
220
+ return
221
+ var named: PackedStringArray = []
222
+ for id: int in ids:
223
+ orphans_named[id] = true
224
+ if named.size() < ORPHANS_NAMED:
225
+ var _named: bool = named.append(described(instance_from_id(id) as Node))
226
+ var more: String = "" if ids.size() <= named.size() else ", and %d more" % (ids.size() - named.size())
227
+ print(
228
+ (
229
+ "%s %s > %s left %d: %s%s"
230
+ % [ORPHANS_LEFT, suite_running, during, ids.size(), ", ".join(named), more]
231
+ )
232
+ )
233
+
234
+ ## The nodes outside every tree that are not on their way out, by instance id, as gdUnit4 counts
235
+ ## them: one queued for deletion is freed at the end of the frame.
236
+ static func orphan_ids() -> Dictionary[int, bool]:
237
+ var ids: Dictionary[int, bool] = {}
238
+ for id: int in Node.get_orphan_node_ids():
239
+ var node: Node = instance_from_id(id) as Node
240
+ if node != null and not node.is_queued_for_deletion():
241
+ ids[id] = true
242
+ return ids
243
+
244
+ ## [param node] as an orphan line names it: its class, its name when it was given one, and its
245
+ ## script when it has one.
246
+ static func described(node: Node) -> String:
247
+ var said: String = node.get_class()
248
+ if not node.name.is_empty():
249
+ said += ' "%s"' % node.name
250
+ var attached: Variant = node.get_script()
251
+ if attached is Script:
252
+ var script: Script = attached
253
+ if not script.resource_path.is_empty():
254
+ said += " of " + script.resource_path
255
+ return said
164
256
 
165
257
  ## [param args] with everything up to the script at [param own] given as gdUnit4's tool name. The
166
258
  ## path is compared with its separators and, on Windows, its case folded, since the engine
package/build/keeper.js CHANGED
@@ -8308,6 +8308,11 @@ var IGNORING_CASE = " \\(ignoring case\\)";
8308
8308
  var PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
8309
8309
  var QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
8310
8310
  var COLOUR = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
8311
+ var HOOKS_LEFT = new Map([
8312
+ ["before", "before()"],
8313
+ ["after", "after()"],
8314
+ ["before or after", "before() or after()"]
8315
+ ]);
8311
8316
 
8312
8317
  // src/suite-judge.ts
8313
8318
  var TIME = String.raw`(?:\d+h )?(?:\d+min )?(?:\d+s )?\d+ms`;
@@ -8324,9 +8329,10 @@ var WINDOWS_EXITS = new Map([
8324
8329
  ]);
8325
8330
  var MEMORY_EXITS = new Set([3221225495, 3221225773]);
8326
8331
  var GDUNIT_FAILED = 100;
8332
+ var GDUNIT_ORPHANS = 101;
8327
8333
  var GDUNIT_EXITS = new Map([
8328
8334
  [GDUNIT_FAILED, "gdUnit4's code for a case that failed or errored"],
8329
- [101, "gdUnit4's code for orphan nodes left behind"],
8335
+ [GDUNIT_ORPHANS, "gdUnit4's code for orphan nodes left behind"],
8330
8336
  [103, "gdUnit4's code for a suite that cannot run headless"],
8331
8337
  [104, "gdUnit4's code for a Godot version it does not support"],
8332
8338
  [105, "gdUnit4's code for scripts that did not compile"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",