gdharness 1.3.46 → 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.
@@ -121,6 +121,26 @@ static func _runnable(config: ConfigFile, section: String) -> bool:
121
121
  return GdhRead.as_bool(config.get_value(section, "runnable", false))
122
122
 
123
123
 
124
+ ## The name of the preset [param platform] runs, or "" when it has none: the one its section names,
125
+ ## or in a file saved before that section, the last preset of the platform keeping the key, which is
126
+ ## the one the editor ends up running. Read from the section alone, a file of that age had no
127
+ ## runnable preset, so a preset added for its platform took the place of the one it had.
128
+ static func _runnable_name(config: ConfigFile, platform: String) -> String:
129
+ if config.has_section_key(RUNNABLE_SECTION, platform):
130
+ return str(config.get_value(RUNNABLE_SECTION, platform))
131
+ var named: String = ""
132
+ var at: int = 0
133
+ while config.has_section("preset." + str(at)):
134
+ var section: String = "preset." + str(at)
135
+ if (
136
+ str(config.get_value(section, "platform", "")) == platform
137
+ and GdhRead.as_bool(config.get_value(section, "runnable", false))
138
+ ):
139
+ named = str(config.get_value(section, "name", ""))
140
+ at += 1
141
+ return named
142
+
143
+
124
144
  ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
125
145
  ## writes for a new one, and every option in [code]known_options[/code] (what
126
146
  ## [method export_options] answers for the platform) at its default. The editor also writes the
@@ -160,7 +180,7 @@ func add_export_preset(params: Dictionary) -> Dictionary:
160
180
  )
161
181
  index += 1
162
182
  # A platform with no preset to run takes the new one, as the editor's dialog does.
163
- var runnable: bool = not config.has_section_key(RUNNABLE_SECTION, platform)
183
+ var runnable: bool = _runnable_name(config, platform).is_empty()
164
184
  if runnable:
165
185
  config.set_value(RUNNABLE_SECTION, platform, preset_name)
166
186
 
@@ -609,6 +629,7 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
609
629
  var index: int = found["index"]
610
630
  var section: String = found["section"]
611
631
  var platform: String = str(config.get_value(section, "platform", ""))
632
+ var was_runnable: bool = _runnable_name(config, platform) == preset_name
612
633
  var last: int = index
613
634
  while config.has_section("preset." + str(last + 1)):
614
635
  last += 1
@@ -622,11 +643,17 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
622
643
  if keeping != OK:
623
644
  return _log.failure("Failed to save %s: %s" % [CREDENTIALS_FILE, error_string(keeping)])
624
645
  answer["credentials"] = CREDENTIALS_FILE
625
- if str(config.get_value(RUNNABLE_SECTION, platform, "")) == preset_name:
626
- config.set_value(RUNNABLE_SECTION, platform, null)
627
- answer["runnableNow"] = null
646
+ if was_runnable:
647
+ if config.has_section_key(RUNNABLE_SECTION, platform):
648
+ config.set_value(RUNNABLE_SECTION, platform, null)
649
+ # A file saved before the section can mark another preset of the platform too, which the
650
+ # editor then runs.
651
+ var kept: String = _runnable_name(config, platform)
652
+ answer["runnableNow"] = kept
653
+ if kept.is_empty():
654
+ answer["runnableNow"] = null
628
655
  var at: int = 0
629
- while config.has_section("preset." + str(at)):
656
+ while kept.is_empty() and config.has_section("preset." + str(at)):
630
657
  if str(config.get_value("preset." + str(at), "platform", "")) == platform:
631
658
  var next: String = str(config.get_value("preset." + str(at), "name", ""))
632
659
  config.set_value(RUNNABLE_SECTION, platform, next)
@@ -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] = []