gdharness 1.3.47 → 1.3.48

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.
@@ -16,6 +16,22 @@ const UPDATE_MODE_NAMES: Dictionary[int, String] = {
16
16
  Animation.UPDATE_DISCRETE: "discrete",
17
17
  Animation.UPDATE_CAPTURE: "capture",
18
18
  }
19
+ const INTERPOLATION_NAMES: Dictionary[int, String] = {
20
+ Animation.INTERPOLATION_NEAREST: "nearest",
21
+ Animation.INTERPOLATION_LINEAR: "linear",
22
+ Animation.INTERPOLATION_CUBIC: "cubic",
23
+ Animation.INTERPOLATION_LINEAR_ANGLE: "linear_angle",
24
+ Animation.INTERPOLATION_CUBIC_ANGLE: "cubic_angle",
25
+ }
26
+ ## The track types whose keys are blended between, so how they are and whether a looping animation
27
+ ## blends its last key into its first say how the track moves.
28
+ const INTERPOLATED_TYPES: Array[int] = [
29
+ Animation.TYPE_VALUE,
30
+ Animation.TYPE_POSITION_3D,
31
+ Animation.TYPE_ROTATION_3D,
32
+ Animation.TYPE_SCALE_3D,
33
+ Animation.TYPE_BLEND_SHAPE,
34
+ ]
19
35
  const TRACK_TYPE_NAMES: Dictionary[int, String] = {
20
36
  Animation.TYPE_VALUE: "property",
21
37
  Animation.TYPE_POSITION_3D: "position_3d",
@@ -174,6 +190,10 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
174
190
  if kind == Animation.TYPE_VALUE:
175
191
  var mode: int = anim.value_track_get_update_mode(track)
176
192
  shaped["update"] = UPDATE_MODE_NAMES.get(mode, str(mode))
193
+ if kind in INTERPOLATED_TYPES:
194
+ var interpolation: int = anim.track_get_interpolation_type(track)
195
+ shaped["interpolation"] = INTERPOLATION_NAMES.get(interpolation, str(interpolation))
196
+ shaped["loopWrap"] = anim.track_get_interpolation_loop_wrap(track)
177
197
  var keyframes: Array[Dictionary] = []
178
198
  var methods: Dictionary = {}
179
199
  for key: int in anim.track_get_key_count(track):
@@ -530,6 +530,20 @@ static func _comment_of(line: String) -> String:
530
530
 
531
531
  # How a line that names the resource by path uses it.
532
532
  func _path_reference_kind(line: String, file_path: String) -> String:
533
+ var placed: String = _placed_kind(line, file_path)
534
+ if not placed.is_empty():
535
+ return placed
536
+ var trimmed: String = GdhPatterns.without_annotations(line)
537
+ if "preload(" in trimmed:
538
+ return "preload"
539
+ if "load(" in trimmed:
540
+ return "load"
541
+ return "path"
542
+
543
+
544
+ # The kind a reference on [param line] has by the file it is in or by how the line starts, or ""
545
+ # when neither says.
546
+ static func _placed_kind(line: String, file_path: String) -> String:
533
547
  match file_path.get_file():
534
548
  "project.godot":
535
549
  return "setting"
@@ -546,11 +560,31 @@ func _path_reference_kind(line: String, file_path: String) -> String:
546
560
  return "ext_resource"
547
561
  if trimmed.begins_with("#include"):
548
562
  return "include"
549
- if "preload(" in trimmed:
563
+ return ""
564
+
565
+
566
+ # The kind of the reference [param found] is in [param content], read from [param path], by the
567
+ # call its path is handed to rather than by the line, which can name a second file another way: a
568
+ # path only written in a string, which nothing in the file may load, read as a load.
569
+ static func _forward_kind(content: String, found: RegExMatch, path: String) -> String:
570
+ var matched: String = found.get_string()
571
+ if matched.begins_with("preload("):
550
572
  return "preload"
551
- if "load(" in trimmed:
573
+ if matched.begins_with("load("):
552
574
  return "load"
553
- return "path"
575
+ var start: int = found.get_start()
576
+ var line_start: int = content.rfind("\n", start) + 1
577
+ var line_end: int = content.find("\n", start)
578
+ var line: String = content.substr(
579
+ line_start, (content.length() if line_end == -1 else line_end) - line_start
580
+ )
581
+ var before: String = content.substr(line_start, start - line_start).rstrip("\"'")
582
+ if before.ends_with("preload("):
583
+ return "preload"
584
+ if before.ends_with("load("):
585
+ return "load"
586
+ var placed: String = _placed_kind(line, path)
587
+ return "path" if placed.is_empty() else placed
554
588
 
555
589
 
556
590
  func _regex_escaped(text: String) -> String:
@@ -633,8 +667,14 @@ func _depend(dep_info: Dictionary, current_depth: int, walk: GdhDependencyWalk)
633
667
  # tree answered that file as a dependency of itself.
634
668
  if dep_path in walk.path_stack:
635
669
  var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(dep_path))
636
- cycle.append(dep_path)
637
- if not cycle in walk.circular_references:
670
+ # Kept by a key that is the same wherever the walk entered the cycle: compared as written,
671
+ # one cycle met from two of its files was listed twice, and comparing each against every
672
+ # cycle found so far grew with the square of their number, sixteen thousand on three
673
+ # hundred scripts typed by each other's class names.
674
+ var key: String = _cycle_key(cycle)
675
+ if not walk.cycles_seen.has(key):
676
+ walk.cycles_seen[key] = true
677
+ cycle.append(dep_path)
638
678
  walk.circular_references.append(cycle)
639
679
  dep_info["circular"] = true
640
680
  return false
@@ -660,6 +700,16 @@ func _depend(dep_info: Dictionary, current_depth: int, walk: GdhDependencyWalk)
660
700
  return walked["cut"]
661
701
 
662
702
 
703
+ ## [param cycle], the files of one cycle in the order the walk met them, turned to start at the
704
+ ## first of them in sorting order, so the cycle met from any of its files keys the same.
705
+ static func _cycle_key(cycle: Array[String]) -> String:
706
+ var first: int = 0
707
+ for at: int in cycle.size():
708
+ if cycle[at] < cycle[first]:
709
+ first = at
710
+ return "\n".join(PackedStringArray(cycle.slice(first) + cycle.slice(0, first)))
711
+
712
+
663
713
  ## What [param path] names, each file once, as the entries the tree lists it by; read once a walk.
664
714
  func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
665
715
  if walk.references.has(path):
@@ -688,12 +738,13 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
688
738
  for m: RegExMatch in regex.search_all(content):
689
739
  var written: String = _resolved(_referenced_path(m.get_string()), path)
690
740
  var dep_path: String = str(moved.get(written, written))
741
+ var kind: String = _forward_kind(content, m, path)
691
742
 
692
743
  # A uid the project does not know names no file, and loading it fails: passed over
693
744
  # as no res:// path, a script's load of one went unsaid while the game printed
694
745
  # "Unrecognized UID" at that line.
695
746
  if dep_path.begins_with("uid://"):
696
- _name(deps, path, dep_path, {})
747
+ _name(deps, path, dep_path, { "kind": kind })
697
748
  continue
698
749
  if not dep_path.begins_with("res://"):
699
750
  continue
@@ -709,7 +760,9 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
709
760
  if not walk.include_built_in and dep_path.begins_with("res://."):
710
761
  continue
711
762
 
712
- var named_by: Dictionary = { "stale_path": written } if dep_path != written else {}
763
+ var named_by: Dictionary = { "kind": kind }
764
+ if dep_path != written:
765
+ named_by["stale_path"] = written
713
766
  if unknown.has(dep_path):
714
767
  named_by["unknown_uid"] = unknown[dep_path]
715
768
  _name(deps, path, dep_path, named_by)
@@ -730,8 +783,8 @@ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
730
783
 
731
784
  # Adds [param dep_path] to [param deps], what [param path] depends on, once however many times it
732
785
  # is named. [param named_by] is the class or autoload name it was named by, as by_class or
733
- # by_autoload, and the path written when the engine loads another, as stale_path; empty for a path
734
- # that is the file.
786
+ # by_autoload, or for a path its kind, with the path written when the engine loads another as
787
+ # stale_path.
735
788
  func _name(deps: Array[Dictionary], path: String, dep_path: String, named_by: Dictionary) -> void:
736
789
  if dep_path == path:
737
790
  return
@@ -962,6 +1015,9 @@ class GdhDependencyWalk:
962
1015
 
963
1016
  var circular_references: Array
964
1017
 
1018
+ ## Each cycle in circular_references, by [method _cycle_key].
1019
+ var cycles_seen: Dictionary[String, bool] = {}
1020
+
965
1021
  func _init(p_max_depth: int, p_include_built_in: bool, p_circular_references: Array) -> void:
966
1022
  max_depth = p_max_depth
967
1023
  include_built_in = p_include_built_in
@@ -356,6 +356,12 @@ static func _entry(
356
356
  var data: Dictionary = {
357
357
  "name": node["name"], "type": node["type"], "path": path, "children": children,
358
358
  }
359
+ var given_values: Dictionary = node["properties"]
360
+ var attached: Variant = given_values.get("script")
361
+ if attached is Script:
362
+ var script: Script = attached
363
+ if not str(script.get_global_name()).is_empty():
364
+ data["scriptClass"] = str(script.get_global_name())
359
365
  # The root's file is the scene being read, so only another node's names a second scene.
360
366
  if path != "." and node.has("instanceOf"):
361
367
  data["instanceOf"] = node["instanceOf"]
@@ -212,7 +212,7 @@ func set_import_options(params: Dictionary) -> Dictionary:
212
212
  var refused: Array[String] = []
213
213
  for key: Variant in options:
214
214
  var name: String = str(key)
215
- var held: Variant = config.get_value("params", name, null)
215
+ var held: Variant = _held(config, name)
216
216
  var given: Variant = _values.deserialize_value(options[key])
217
217
  var value: Variant = _as_option(_values.fitted(options[key], typeof(held)), held)
218
218
  if value is Dictionary:
@@ -248,7 +248,7 @@ func set_import_options(params: Dictionary) -> Dictionary:
248
248
  var unchanged_keys: Array[String] = []
249
249
  var changes: Dictionary = {}
250
250
  for name: String in fitted_values:
251
- var held: Variant = config.get_value("params", name, null)
251
+ var held: Variant = _held(config, name)
252
252
  var value: Variant = fitted_values[name]
253
253
  if held != null and typeof(held) == typeof(value) and held == value:
254
254
  unchanged_keys.append(name)
@@ -275,6 +275,14 @@ func set_import_options(params: Dictionary) -> Dictionary:
275
275
  return answer
276
276
 
277
277
 
278
+ ## What the option [param name] holds in [param config]'s params, or null when it holds none. Asked
279
+ ## with a default of null, the engine prints an error for a key the file does not have.
280
+ static func _held(config: ConfigFile, name: String) -> Variant:
281
+ if not config.has_section_key("params", name):
282
+ return null
283
+ return config.get_value("params", name)
284
+
285
+
278
286
  ## [param given] as the option holds it. Every JSON number arrives as a float, and an enum option
279
287
  ## such as compress/mode written as 1.0 is kept that way by the import, so a whole number becomes
280
288
  ## an int unless the option already holds a float.
@@ -413,6 +413,9 @@ func add_autoload(params: Dictionary) -> Dictionary:
413
413
  return _log.failure(
414
414
  GdhFileWalk.new().with_nearest_files("Autoload file does not exist: " + path, path)
415
415
  )
416
+ var unloadable: String = _not_an_autoload(path)
417
+ if not unloadable.is_empty():
418
+ return _log.failure(unloadable)
416
419
 
417
420
  # Through ProjectSettings so the file is saved the way the editor saves it, which includes
418
421
  # putting Godot's header back and dropping any comment somebody wrote into the file by hand.
@@ -453,6 +456,38 @@ func add_autoload(params: Dictionary) -> Dictionary:
453
456
  return answer
454
457
 
455
458
 
459
+ ## Why the file at [param path] cannot be an autoload, or empty when it can, as the editor's
460
+ ## Autoload
461
+ ## tab judges it: a scene, or a script whose instances are nodes. Written anyway, a script extending
462
+ ## RefCounted saved fine and every start of the game then failed to instantiate it. A script that
463
+ ## does not compile is let through, since it may be one being written, and its errors are its own.
464
+ static func _not_an_autoload(path: String) -> String:
465
+ var refused: String = "%s is %s, and an autoload is a scene or a script extending Node"
466
+ if not ResourceLoader.exists(path):
467
+ return (refused + "; nothing was written.") % [path, "a file Godot does not load"]
468
+ var loaded: Resource = load(path)
469
+ if loaded is PackedScene:
470
+ return ""
471
+ if not loaded is Script:
472
+ var kind: String = "nothing Godot could load"
473
+ if loaded != null:
474
+ kind = "a " + loaded.get_class()
475
+ return (refused + "; nothing was written.") % [path, kind]
476
+ var script: Script = loaded
477
+ if not script.can_instantiate():
478
+ return ""
479
+ var base: StringName = script.get_instance_base_type()
480
+ if ClassDB.is_parent_class(base, "Node"):
481
+ return ""
482
+ return (
483
+ (
484
+ "%s extends %s, which is not a Node, and Godot instantiates an autoload under the root "
485
+ + "as a node, so every start of the game would fail to load it; nothing was written."
486
+ )
487
+ % [path, base]
488
+ )
489
+
490
+
456
491
  ## Why [param name] cannot name an autoload, or empty when it can. The editor's Autoload tab refuses
457
492
  ## the same names: an autoload named after an engine class or a built-in type takes that name in
458
493
  ## every script, so an autoload called Input made every Input.is_action_pressed call the autoload.
@@ -36,10 +36,9 @@ func get_uid(params: Dictionary) -> Dictionary:
36
36
  var answer: Dictionary = { "file": file_path, "absolute_path": absolute_path }
37
37
  var found: Dictionary = uid_of(file_path)
38
38
  if found.is_empty():
39
- answer["exists"] = false
39
+ answer["uid"] = null
40
40
  answer["message"] = _why_none(file_path)
41
41
  return answer
42
- answer["exists"] = true
43
42
  answer.merge(found)
44
43
  return answer
45
44
 
@@ -357,6 +357,9 @@ class GdhRunner:
357
357
  var case_began: bool = false
358
358
  ## The suite running, which a case's own event does not carry.
359
359
  var suite_running: String = ""
360
+ ## Whether gdUnit4 has minimised the window, or is about to, and it is not back yet: it does as
361
+ ## its runner starts, and after every suite.
362
+ var minimise_pending: bool = true
360
363
 
361
364
  ## As the run ends, the marker of the walk's newest end, and every script held past the walk, on
362
365
  ## an engine whose walk can stop part way ([GdhShutdownWalk]).
@@ -446,7 +449,11 @@ class GdhRunner:
446
449
  GdUnitEvent.SESSION_START:
447
450
  if not GdhIndexedSession.indexes_itself(_test_session):
448
451
  _test_session = GdhIndexedSession.of(_test_session)
452
+ var tree: SceneTree = get_tree()
453
+ if not tree.process_frame.is_connected(drawn_window):
454
+ var _watched: int = tree.process_frame.connect(drawn_window)
449
455
  GdUnitEvent.TESTSUITE_BEFORE:
456
+ drawn_window()
450
457
  suite_running = event.resource_path()
451
458
  orphans_at_suite = orphan_ids()
452
459
  orphans_named.clear()
@@ -461,6 +468,9 @@ class GdhRunner:
461
468
  GdUnitEvent.TESTCASE_AFTER:
462
469
  cases_ended += 1
463
470
  super(event)
471
+ # gdUnit4 minimises the window once the suite's end is reported.
472
+ if event.type() == GdUnitEvent.TESTSUITE_AFTER:
473
+ minimise_pending = true
464
474
  # After gdUnit4's own report of the case, so the line falls under it.
465
475
  if event.orphan_nodes() == 0:
466
476
  return
@@ -478,6 +488,21 @@ class GdhRunner:
478
488
  "after", left.filter(func(id: int) -> bool: return not orphans_from_before.has(id))
479
489
  )
480
490
 
491
+ ## The window back from minimised, which gdUnit4 makes it as its runner starts and after every
492
+ ## suite, and where Godot draws nothing: a suite given a window to render in waited for a
493
+ ## frame_post_draw that never came. Called as a suite begins, ahead of its before(), and every
494
+ ## frame after, since on macOS the minimise is an animation that reads as windowed until it
495
+ ## ends, and a window not yet minimised cannot be put back. Only once for each minimise of
496
+ ## gdUnit4's, so a suite minimising the window itself keeps it minimised.
497
+ func drawn_window() -> void:
498
+ if not minimise_pending or DisplayServer.get_name() == "headless":
499
+ return
500
+ if Engine.is_embedded_in_editor():
501
+ return
502
+ if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_MINIMIZED:
503
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
504
+ minimise_pending = false
505
+
481
506
  ## The orphans now that were not there in [param before] and no line has named yet.
482
507
  func new_orphans(before: Dictionary[int, bool]) -> Array[int]:
483
508
  var found: Array[int] = []