gdharness 1.3.5 → 1.3.7

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.
@@ -152,7 +152,7 @@ static func not_of_kind(
152
152
  if not found.is_empty():
153
153
  told += ' The node at "%s" is %s, not %s.' % [node_path, with_article(found), with_article(kind)]
154
154
  if holders.is_empty():
155
- return told + " The scene holds no %s." % kind
155
+ return told + " The scene holds no %s; scene_node add with nodeType %s puts one in it." % [kind, kind]
156
156
  var quoted: PackedStringArray = []
157
157
  for holder: String in holders.slice(0, NAMED_CHILDREN):
158
158
  var _quoted: bool = quoted.append('"%s"' % holder)
@@ -120,6 +120,7 @@ func _init_tools() -> void:
120
120
  "play_scene": [_play_tools, "play_scene"],
121
121
  "restart_editor": [_play_tools, "restart_editor"],
122
122
  "quit_editor": [_play_tools, "quit_editor"],
123
+ "editor_servers": [_play_tools, "editor_servers"],
123
124
  "stop_playing": [_play_tools, "stop_playing"],
124
125
  "playing_status": [_play_tools, "playing_status"],
125
126
  # The script profiler on a game the editor plays
@@ -170,17 +170,18 @@ func restart_editor(_args: Dictionary) -> Dictionary:
170
170
  )
171
171
  }
172
172
 
173
- var unsaved: Dictionary = _save_everything()
174
- if not unsaved.is_empty():
175
- return unsaved
173
+ var saving: Dictionary = _save_everything()
174
+ if not saving.has("saved"):
175
+ return saving
176
176
 
177
177
  # Deferred so this answer is on its way out before the editor goes.
178
178
  EditorInterface.restart_editor.call_deferred(true)
179
179
 
180
- return {"ok": true, "restarting": true, "saved": true}
180
+ return {"ok": true, "restarting": true, "saved": saving["saved"]}
181
181
 
182
182
 
183
- ## Saves every open scene, answering a refusal naming what is still unsaved, or {}.
183
+ ## Saves every open scene, answering a refusal naming what is still unsaved, or the scenes that had
184
+ ## changes and were saved, under `saved`.
184
185
  ##
185
186
  ## Read back rather than assumed: save_all_scenes answers nothing and passes over a scene that has
186
187
  ## never been saved, which has no path to go to, so an untitled scene was lost with the editor while
@@ -188,10 +189,11 @@ func restart_editor(_args: Dictionary) -> Dictionary:
188
189
  static func _save_everything() -> Dictionary:
189
190
  if EditorInterface.is_playing_scene():
190
191
  EditorInterface.stop_playing_scene()
192
+ var changed: PackedStringArray = EditorInterface.get_unsaved_scenes()
191
193
  EditorInterface.save_all_scenes()
192
194
  var left: PackedStringArray = EditorInterface.get_unsaved_scenes()
193
195
  if left.is_empty():
194
- return {}
196
+ return {"ok": true, "saved": changed}
195
197
  var named: Array[String] = []
196
198
  for scene: String in left:
197
199
  named.append(scene if not scene.is_empty() else "a scene that has never been saved")
@@ -221,14 +223,62 @@ static func _save_everything() -> Dictionary:
221
223
  ## Saved first, exactly as the restart above saves. The editor is going either way, and unsaved
222
224
  ## scenes are not this tool's to lose.
223
225
  func quit_editor(_args: Dictionary) -> Dictionary:
224
- var unsaved: Dictionary = _save_everything()
225
- if not unsaved.is_empty():
226
- return unsaved
226
+ var saving: Dictionary = _save_everything()
227
+ if not saving.has("saved"):
228
+ return saving
227
229
 
228
230
  # Deferred so this answer is on its way out before the editor goes.
229
231
  _leave.call_deferred()
230
232
 
231
- return {"ok": true, "quitting": true, "saved": true}
233
+ return {"ok": true, "quitting": true, "saved": saving["saved"]}
234
+
235
+
236
+ ## What the editor said as it started each of its servers, and the debug adapter started again when
237
+ ## [param args] names it under `rebind`.
238
+ ##
239
+ ## Godot binds each server once and says whether it could only in the editor's log panel, as a line
240
+ ## like `--- Failed to start Debug adapter server on port 6006: Already in use ---`, so those lines
241
+ ## are the one account of why a port it names has nothing on it. They are read before anything is
242
+ ## started again, so the answer carries the first attempt's reason as well as the next one's.
243
+ ##
244
+ ## The debug adapter binds as its node enters the tree, which is early in the editor's start and
245
+ ## seconds before the language server, and does not try again; taking its node out of the tree and
246
+ ## putting it back runs that start once more. The language server starts on the editor's first
247
+ ## frame after it is ready and only once, so re-entering the tree would stop it for good, and it is
248
+ ## never touched here.
249
+ func editor_servers(args: Dictionary) -> Dictionary:
250
+ var said: PackedStringArray = _servers_said()
251
+ var rebound: bool = false
252
+ if GdhRead.as_bool(args.get("rebind", false)):
253
+ var adapter: Node = _debug_adapter()
254
+ if adapter != null:
255
+ var parent: Node = adapter.get_parent()
256
+ var at: int = adapter.get_index()
257
+ parent.remove_child(adapter)
258
+ parent.add_child(adapter)
259
+ parent.move_child(adapter, at)
260
+ rebound = true
261
+ return {"ok": true, "said": said, "rebound": rebound, "saidSince": _servers_said().slice(said.size())}
262
+
263
+
264
+ ## The lines the editor's log panel holds about its language server and debug adapter, in order.
265
+ static func _servers_said() -> PackedStringArray:
266
+ var lines: PackedStringArray = []
267
+ var root: Node = EditorInterface.get_base_control().get_tree().root
268
+ for panel: Node in root.find_children("*", "EditorLog", true, false):
269
+ for shown: Node in panel.find_children("*", "RichTextLabel", true, false):
270
+ for line: String in (shown as RichTextLabel).get_parsed_text().split("\n", false):
271
+ var stripped: String = line.strip_edges()
272
+ if stripped.begins_with("---") and stripped.contains(" server"):
273
+ var _kept: bool = lines.append(stripped)
274
+ return lines
275
+
276
+
277
+ ## The editor's debug adapter node, found by its engine class, or null in an editor without one.
278
+ static func _debug_adapter() -> Node:
279
+ var root: Node = EditorInterface.get_base_control().get_tree().root
280
+ var found: Array[Node] = root.find_children("*", "DebugAdapterServer", true, false)
281
+ return found[0] if not found.is_empty() else null
232
282
 
233
283
 
234
284
  ## Asked of the tree the editor's own window is in, because this object is not in one.
@@ -285,6 +285,17 @@ func modify_resource(args: Dictionary) -> Dictionary:
285
285
  var resource: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
286
286
  if resource == null:
287
287
  return {"ok": false, "error": "%s did not load as a resource." % res_path}
288
+ # A scene's own properties are its packed bundle, so a property named for a scene is one of its
289
+ # nodes', and "PackedScene has no property" left the caller looking for the tool that sets it.
290
+ if resource is PackedScene:
291
+ return {
292
+ "ok": false,
293
+ "error":
294
+ (
295
+ "%s is a scene: scene_node set changes one node's properties, the root's at nodePath \".\"."
296
+ % res_path
297
+ )
298
+ }
288
299
 
289
300
  var properties: Dictionary = _parse_properties_dict(args.get("properties", ""))
290
301
  var refused: String = _properties.write_all(resource, properties)
@@ -152,7 +152,7 @@ static func not_of_kind(
152
152
  if not found.is_empty():
153
153
  told += ' The node at "%s" is %s, not %s.' % [node_path, with_article(found), with_article(kind)]
154
154
  if holders.is_empty():
155
- return told + " The scene holds no %s." % kind
155
+ return told + " The scene holds no %s; scene_node add with nodeType %s puts one in it." % [kind, kind]
156
156
  var quoted: PackedStringArray = []
157
157
  for holder: String in holders.slice(0, NAMED_CHILDREN):
158
158
  var _quoted: bool = quoted.append('"%s"' % holder)
@@ -67,6 +67,13 @@ const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
67
67
  ## Serve a script run anyway, for somebody driving a `-s` script rather than a game.
68
68
  const SCRIPT_RUNS_SETTING: String = "gdharness/runtime/serve_script_runs"
69
69
 
70
+ ## The `res://` folders of scenes that are not the game, such as a bench: a run started on one is
71
+ ## not served. Kept in step with `QUIET_SCENES_SETTING` in src/setup.ts.
72
+ const QUIET_SCENES_SETTING: String = "gdharness/runtime/quiet_scenes_under"
73
+
74
+ ## What the engine takes a bare argument for as the scene to run, by its extension.
75
+ const SCENE_EXTENSIONS: PackedStringArray = ["tscn", "scn", "escn", "res", "tres"]
76
+
70
77
  ## The variable the editor addon puts into its own environment, which every game the editor plays
71
78
  ## inherits: it says which editor played this game, so a server can tell the editor's game from
72
79
  ## another of the same project that some other server started. Kept in step with
@@ -123,6 +130,9 @@ var _commands: Dictionary = {}
123
130
  var _loaded_version: String = _read_marker(VERSION_MARKER)
124
131
  var _loaded_digest: String = _read_marker(DIGEST_MARKER)
125
132
  var _profile_received: bool = false
133
+ ## Whether whatever started this game asked for a profile from its start, read before the variable
134
+ ## is taken out of the environment.
135
+ var _profiled_from_start: bool = false
126
136
  var _close_asked: bool = false
127
137
  var _close_frames: int = 0
128
138
  var _profiler_calibrated: bool = false
@@ -200,7 +210,9 @@ static func _window_dimension(axis: String, fallback: int) -> int:
200
210
  func _wait_for_the_profiler() -> void:
201
211
  if OS.get_environment(PROFILE_FROM_START).is_empty():
202
212
  return
203
- # Read once: a process this game starts inherits its environment and must not wait as well.
213
+ _profiled_from_start = true
214
+ # Read once: a process this game starts inherits its environment and must not wait as well, nor
215
+ # be served as a profiled run where a quiet scene would not be, as a bench's workers are.
204
216
  OS.unset_environment(PROFILE_FROM_START)
205
217
  if not EngineDebugger.is_active():
206
218
  return
@@ -446,6 +458,15 @@ func _start_server() -> void:
446
458
  _enabled = false
447
459
  return
448
460
 
461
+ # Nor a scene the project says is not the game. A bench is handed to the engine as a scene, the
462
+ # way a screen is, so it announced as the game for as long as it ran, and so did every worker
463
+ # engine it started, all under the project's own path. One started to be profiled from its start
464
+ # is served all the same, since only this addon hands the totals back as it quits.
465
+ var prefixes: Variant = ProjectSettings.get_setting(QUIET_SCENES_SETTING, PackedStringArray())
466
+ if not _profiled_from_start and not quiet_scene_in(OS.get_cmdline_args(), prefixes).is_empty():
467
+ _enabled = false
468
+ return
469
+
449
470
  _server = TCPServer.new()
450
471
  # listen() defaults bind_address to "*", which exposes the game to the whole network.
451
472
  var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
@@ -474,6 +495,29 @@ func _start_server() -> void:
474
495
  print(listening)
475
496
 
476
497
 
498
+ ## The scene [param given] names for the engine to run, as a `res://` path, when it lies under one of
499
+ ## [param prefixes]; otherwise empty. The engine takes any bare argument ending in a scene's
500
+ ## extension for the scene, relative to the project or absolute, so each is read as it would.
501
+ static func quiet_scene_in(given: PackedStringArray, prefixes: Variant) -> String:
502
+ # A PackedStringArray as project.godot writes one, or an Array of strings as a hand might.
503
+ var folders: PackedStringArray = []
504
+ if prefixes is PackedStringArray:
505
+ folders = prefixes
506
+ elif prefixes is Array:
507
+ var listed: Array = prefixes
508
+ for prefix: Variant in listed:
509
+ if prefix is String:
510
+ var _kept: bool = folders.append(str(prefix))
511
+ for argument: String in given:
512
+ if argument.begins_with("-") or not SCENE_EXTENSIONS.has(argument.get_extension().to_lower()):
513
+ continue
514
+ var scene: String = ProjectSettings.localize_path(argument)
515
+ for folder: String in folders:
516
+ if not folder.is_empty() and scene.begins_with(folder):
517
+ return scene
518
+ return ""
519
+
520
+
477
521
  ## Whether the engine was told to run a script rather than the game.
478
522
  func _script_run() -> bool:
479
523
  var given: PackedStringArray = OS.get_cmdline_args()
@@ -10,6 +10,9 @@ const GdhScreen = preload("runtime_screen.gd")
10
10
  ## How far zoom enlarges at most. Sixteen makes a 3-pixel ring 48 pixels across, past any need to
11
11
  ## see it, and a 200-pixel region 3200, past the size anybody reads a picture at.
12
12
  const ZOOM_MOST: int = 16
13
+ ## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
14
+ ## in the PNG, at a fifth of the PNG's size.
15
+ const JPEG_QUALITY: float = 0.8
13
16
 
14
17
  var _host: Node
15
18
  var _values: GdhValues
@@ -385,16 +388,28 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
385
388
  % screenshot_path.get_base_dir()
386
389
  )
387
390
  )
388
- var save_error: Error = image.save_png(screenshot_path)
391
+ var answer: Dictionary = saved(image, screenshot_path)
392
+ answer.merge(described)
393
+ return answer
394
+
395
+
396
+ ## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
397
+ ## instead when it is the smaller: a rendered frame is several times smaller as one, and every
398
+ ## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
399
+ ## would flatten.
400
+ static func saved(image: Image, png_path: String) -> Dictionary:
401
+ var save_error: Error = image.save_png(png_path)
389
402
  if save_error != OK:
390
403
  return {"type": "error", "message": "Failed to save screenshot as PNG: " + str(save_error)}
391
-
392
404
  var answer: Dictionary = {
393
405
  "type": "screenshot_file",
394
406
  "format": "png",
395
407
  "width": image.get_width(),
396
408
  "height": image.get_height(),
397
- "path": screenshot_path
409
+ "path": png_path
398
410
  }
399
- answer.merge(described)
411
+ if image.detect_alpha() == Image.ALPHA_NONE:
412
+ var jpeg_path: String = png_path.get_basename() + ".jpg"
413
+ if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
414
+ answer["jpegPath"] = jpeg_path
400
415
  return answer
@@ -9,7 +9,7 @@ extends RefCounted
9
9
  ## platform when the pointer crosses into the window, sets it. An injected event never crosses
10
10
  ## anything, and on gdharness's own desktop no pointer ever enters a window, so nothing was hovered.
11
11
  ## A BaseButton takes a mouse press only while hovered, so every click on a button in such a window
12
- ## was dropped while the press itself was routed to it. Reported from Tall Tales Inn, whose strip
12
+ ## was dropped while the press itself was routed to it. Reported from Game C, whose strip
13
13
  ## window holds its first-run question, and traced through Godot 4.7.2's source.
14
14
  ##
15
15
  ## What is supplied is what the engine does once the window has been entered: the control its own
@@ -257,7 +257,7 @@ static func can_read(holder: Variant, named: String) -> bool:
257
257
  ## counted as unclean for a read.
258
258
  ##
259
259
  ## A step written in double quotes is the key inside them, so a key holding a colon is one step:
260
- ## Alchemill keys its stock by namespaced ids, "stock:\"base:dew\"", and split at that colon the
260
+ ## Game D keys its stock by namespaced ids, "stock:\"base:dew\"", and split at that colon the
261
261
  ## step named a key "base" the map had never had.
262
262
  static func _key_in(map: Dictionary, named: String) -> Array:
263
263
  var word: String = key_written(named)
@@ -152,7 +152,7 @@ static func not_of_kind(
152
152
  if not found.is_empty():
153
153
  told += ' The node at "%s" is %s, not %s.' % [node_path, with_article(found), with_article(kind)]
154
154
  if holders.is_empty():
155
- return told + " The scene holds no %s." % kind
155
+ return told + " The scene holds no %s; scene_node add with nodeType %s puts one in it." % [kind, kind]
156
156
  var quoted: PackedStringArray = []
157
157
  for holder: String in holders.slice(0, NAMED_CHILDREN):
158
158
  var _quoted: bool = quoted.append('"%s"' % holder)
@@ -0,0 +1,263 @@
1
+ extends SceneTree
2
+
3
+ ## gdUnit4's command-line runner over the suites a file lists, in the file's order. One `-a` a suite
4
+ ## outgrows the command line once a game has a few hundred suites (Windows stops at 32767
5
+ ## characters), so the suites come from the file named after `--` with [constant LISTED], and the
6
+ ## cases to leave out from the one named with [constant SKIPPING]. Every other argument is gdUnit4's
7
+ ## own, as `GdUnitCmdTool.gd` takes them.
8
+ ##
9
+ ## Started from outside the project, `-s` with this file's absolute path, so it declares no class of
10
+ ## its own and reaches gdUnit4 through the project's global classes. Besides the file of suites it
11
+ ## finds each case by its id in a dictionary ([GdhIndexedSession]), can compile the suites together
12
+ ## before gdUnit4 loads them ([constant TOGETHER]), can turn off the reports the caller does
13
+ ## not read ([constant REPORTS_OFF]), and writes the errors each suite declares it expects
14
+ ## ([constant EXPECTED_OUT]).
15
+
16
+ const LISTED: String = "--suites-in"
17
+ const SKIPPING: String = "--skipping-in"
18
+ ## The flags turning off a report, with the gdUnit4 hook that writes it. A gate reads what a run
19
+ ## prints, and each report finds every case it hears of in the session to write them all out as the
20
+ ## run ends; project_test reads the JUnit one and turns off only the HTML one.
21
+ const REPORTS_OFF: Dictionary[String, String] = {
22
+ "--html-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd",
23
+ "--junit-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd",
24
+ }
25
+ ## Compiles the suites together beside every class of the game ([method compiled_together]) before
26
+ ## gdUnit4 loads them. A gate asks for it, since it runs every suite and wants every script that
27
+ ## does not compile named. project_test does not: its answer is about the scripts its run reached,
28
+ ## and the compile names every broken script of the game, then once more as the one script it made.
29
+ const TOGETHER: String = "--compile-together"
30
+ ## The file to write the errors every suite declares it expects into, as JSON keyed by suite path and
31
+ ## then case name.
32
+ const EXPECTED_OUT: String = "--expected-errors-out"
33
+ ## The constant a suite declares its expected errors in: case name to the exact texts it prints.
34
+ const EXPECTED: String = "GDHARNESS_EXPECTED_ERRORS"
35
+ ## The tool name gdUnit4 reads its options after.
36
+ const TOOL: String = "GdUnitCmdTool.gd"
37
+ ## How long a windowless engine sleeps after each frame. The engine's own default is the
38
+ ## low-processor pause, 6.8 ms measured, which a run of suites awaiting frames spends thousands of
39
+ ## times; zero makes a frame's delta round to nothing, and a tween then never moves.
40
+ const FRAME_SLEEP_USEC: int = 1000
41
+
42
+ var _runner: GdhRunner = null
43
+ ## The suites of the run, compiled by [method compiled_together] and held for as long as it runs.
44
+ var _together: GDScript = null
45
+
46
+
47
+ ## gdUnit4's runner reading its options after this script's path rather than after its own tool's,
48
+ ## which it looks for by name and otherwise finds none of them, printing its help.
49
+ class GdhRunner:
50
+ extends GdUnitTestCIRunner
51
+
52
+ var own_path: String = ""
53
+
54
+ func get_cmdline_args() -> PackedStringArray:
55
+ return options_of(OS.get_cmdline_args(), own_path)
56
+
57
+ ## The session gdUnit4 has just made, swapped for a [GdhIndexedSession] of the same cases as it
58
+ ## is announced: everything that reads the session, the hooks, the cases and the console printing
59
+ ## every case, is handed it after this.
60
+ func _on_gdunit_event(event: GdUnitEvent) -> void:
61
+ if event.type() == GdUnitEvent.SESSION_START:
62
+ _test_session = GdhIndexedSession.of(_test_session)
63
+ super(event)
64
+
65
+ ## [param args] with everything up to the script at [param own] given as gdUnit4's tool name. The
66
+ ## path is compared with its separators and, on Windows, its case folded, since the engine
67
+ ## reports the script as `X:/a/listed.gd` when it was started as `X:\a\listed.gd`.
68
+ static func options_of(args: PackedStringArray, own: String) -> PackedStringArray:
69
+ var wanted: String = spelt(own)
70
+ var at: int = -1
71
+ for index: int in args.size():
72
+ if spelt(args[index]) == wanted:
73
+ at = index
74
+ break
75
+ if at < 0:
76
+ return args
77
+ var options: PackedStringArray = [TOOL]
78
+ options.append_array(args.slice(at + 1))
79
+ return options
80
+
81
+ static func spelt(path: String) -> String:
82
+ var forward: String = path.replace("\\", "/")
83
+ return forward.to_lower() if OS.get_name() == "Windows" else forward
84
+
85
+
86
+ ## gdUnit4's session finding a case by its id in a dictionary, where its own walks every case of the
87
+ ## session, `GdUnitTestSession.find_test_by_id`: the console asks it twice a case and each report
88
+ ## twice more, so a run's time in it grew with the square of its cases (17.9 s of processor time
89
+ ## in an engine of 2300 cases).
90
+ class GdhIndexedSession:
91
+ extends GdUnitTestSession
92
+
93
+ var _by_id: Dictionary[String, GdUnitTestCase] = {}
94
+
95
+ func _init(cases: Array[GdUnitTestCase], reports_in: String) -> void:
96
+ super(cases, reports_in)
97
+ for test: GdUnitTestCase in _test_cases:
98
+ _by_id[str(test.guid)] = test
99
+
100
+ func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
101
+ var found: GdUnitTestCase = _by_id.get(str(id), null)
102
+ return found
103
+
104
+ ## [param made] as an indexed session of its cases, reporting where it does; one already indexed
105
+ ## as it is.
106
+ static func of(made: GdUnitTestSession) -> GdUnitTestSession:
107
+ if made is GdhIndexedSession:
108
+ return made
109
+ return GdhIndexedSession.new(made._test_cases, made.report_path)
110
+
111
+
112
+ func _initialize() -> void:
113
+ OS.low_processor_usage_mode_sleep_usec = FRAME_SLEEP_USEC
114
+ var asked: PackedStringArray = OS.get_cmdline_user_args()
115
+ var unread: PackedStringArray = []
116
+ for flag: String in REPORTS_OFF:
117
+ if asked.has(flag):
118
+ var _unread: bool = unread.append(REPORTS_OFF[flag])
119
+ if not unread.is_empty():
120
+ var _off: PackedStringArray = unread_off(GdUnitTestSessionHookService.instance(), unread)
121
+ var suites: PackedStringArray = listed(asked, LISTED)
122
+ if asked.has(TOGETHER):
123
+ _together = compiled_together(suites)
124
+ var expected_out: String = argument_after(asked, EXPECTED_OUT)
125
+ if not expected_out.is_empty():
126
+ write_expected(expected_out, suites)
127
+ _runner = GdhRunner.new()
128
+ _runner.own_path = get_script().resource_path
129
+ _runner.add_test_suites(suites)
130
+ _runner.skip_test_suites(listed(asked, SKIPPING))
131
+ root.add_child(_runner)
132
+
133
+
134
+ ## As gdUnit4's own `GdUnitCmdTool.gd` ends, which names in a verbose run the nodes left orphaned.
135
+ func _finalize() -> void:
136
+ queue_delete(_runner)
137
+ if OS.is_stdout_verbose():
138
+ print("Orphan nodes left as the run ends:")
139
+ Window.print_orphan_nodes()
140
+
141
+
142
+ ## Turns off the hooks among [param hooks] whose scripts [param unread] names, answering the scripts
143
+ ## of those it turned off. Through the mark gdUnit4's own `is_enabled` reads, rather than its
144
+ ## `enable_hook`, which writes the setting into `project.godot`: a run changes nothing in the tree.
145
+ static func unread_off(hooks: GdUnitTestSessionHookService, unread: PackedStringArray) -> PackedStringArray:
146
+ var off: PackedStringArray = []
147
+ for hook: GdUnitTestSessionHook in hooks.enigne_hooks:
148
+ var path: String = hook.get_script().resource_path
149
+ if unread.has(path):
150
+ hook.set_meta("enabled", false)
151
+ var _added: bool = off.append(path)
152
+ return off
153
+
154
+
155
+ ## [param suites] compiled inside one script, answered for the caller to hold so gdUnit4's own loads
156
+ ## find them compiled. The engine keeps what it read of a class only while the compile that asked for
157
+ ## it runs (`GDScriptCache`), so a suite compiled on its own reads again every class it reaches a
158
+ ## member of, some seventy milliseconds a suite. As the preloads of one script naming every class of
159
+ ## the game's own first, held from that script's compile, they share one reading of each: two hundred
160
+ ## suites compiled in a third of the time they took one at a time. The names are public, since a
161
+ ## private one unread is a warning and every warning an error, and each a variable, since a class is
162
+ ## no constant expression. A suite that does not compile fails here and again when gdUnit4 loads it,
163
+ ## which reports it as it would have. A directory listed is left to gdUnit4 to walk.
164
+ static func compiled_together(suites: PackedStringArray) -> GDScript:
165
+ var lines: PackedStringArray = ["extends RefCounted"]
166
+ var at: int = 0
167
+ for named: String in held_classes():
168
+ var _held: bool = lines.append("var held_%d: Variant = %s" % [at, named])
169
+ at += 1
170
+ at = 0
171
+ for path: String in suites:
172
+ if not path.ends_with(".gd"):
173
+ continue
174
+ var _suite: bool = lines.append('var suite_%d: Variant = preload("%s")' % [at, path])
175
+ at += 1
176
+ var together: GDScript = GDScript.new()
177
+ together.source_code = "\n".join(lines) + "\n"
178
+ var _compiled: Error = together.reload()
179
+ return together
180
+
181
+
182
+ ## The classes [method compiled_together] names before the suites: the game's own GDScript classes,
183
+ ## none of an addon's or a suite's, then gdUnit4's suite. Only GDScript shares the engine's reading,
184
+ ## and a sandboxed mod's SafeGDScript class would load the sandbox's compiler, some 290 MB and up to
185
+ ## 2.5 s, for nothing.
186
+ static func held_classes(
187
+ classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
188
+ ) -> PackedStringArray:
189
+ var held: PackedStringArray = []
190
+ for known: Dictionary in classes:
191
+ var path: String = known["path"]
192
+ var own: bool = not path.begins_with("res://addons/") and not path.ends_with("_test.gd")
193
+ if own and known["language"] == &"GDScript":
194
+ var _own: bool = held.append(str(known["class"]))
195
+ var _suite: bool = held.append("GdUnitTestSuite")
196
+ return held
197
+
198
+
199
+ ## What the file after [param flag] in [param args] names, one a line in order, or none when no file
200
+ ## is named: gdUnit4 then says it found nothing, which a gate refuses.
201
+ static func listed(args: PackedStringArray, flag: String) -> PackedStringArray:
202
+ var path: String = argument_after(args, flag)
203
+ if path.is_empty():
204
+ return PackedStringArray()
205
+ var named: PackedStringArray = []
206
+ for line: String in FileAccess.get_file_as_string(path).split("\n", false):
207
+ if not line.strip_edges().is_empty():
208
+ var _named: bool = named.append(line.strip_edges())
209
+ return named
210
+
211
+
212
+ ## The argument after [param flag] in [param args], or "" when there is none.
213
+ static func argument_after(args: PackedStringArray, flag: String) -> String:
214
+ var at: int = args.find(flag)
215
+ if at < 0 or at + 1 >= args.size():
216
+ return ""
217
+ return args[at + 1]
218
+
219
+
220
+ ## The errors each of [param suites] declares it expects, by suite path and then case name, read off
221
+ ## the compiled suite's constants so a declaration is whatever the engine made of it. A directory
222
+ ## listed is walked for its scripts, as gdUnit4 walks it for suites. Only a script whose source names
223
+ ## the constant is loaded: gdUnit4 stops on a suite that does not load only when its own load is the
224
+ ## one that fails, so a broken suite loaded here first let the rest of its directory run.
225
+ static func expected_by_suite(suites: PackedStringArray) -> Dictionary:
226
+ var expected: Dictionary = {}
227
+ for path: String in scripts_in(suites):
228
+ if not FileAccess.file_exists(path) or not FileAccess.get_file_as_string(path).contains(EXPECTED):
229
+ continue
230
+ var suite: Script = load(path)
231
+ var declared: Variant = suite.get_script_constant_map().get(EXPECTED)
232
+ if declared is Dictionary:
233
+ expected[path] = declared
234
+ return expected
235
+
236
+
237
+ ## The scripts [param paths] names: each script, and every script under each directory.
238
+ static func scripts_in(paths: PackedStringArray) -> PackedStringArray:
239
+ var found: PackedStringArray = []
240
+ var pending: PackedStringArray = []
241
+ for path: String in paths:
242
+ if path.ends_with(".gd"):
243
+ var _script: bool = found.append(path)
244
+ else:
245
+ var _directory: bool = pending.append(path.trim_suffix("/"))
246
+ while not pending.is_empty():
247
+ var directory: String = pending[pending.size() - 1]
248
+ pending.remove_at(pending.size() - 1)
249
+ for file: String in DirAccess.get_files_at(directory):
250
+ if file.ends_with(".gd"):
251
+ var _file: bool = found.append("%s/%s" % [directory, file])
252
+ for inner: String in DirAccess.get_directories_at(directory):
253
+ var _inner: bool = pending.append("%s/%s" % [directory, inner])
254
+ return found
255
+
256
+
257
+ static func write_expected(path: String, suites: PackedStringArray) -> void:
258
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
259
+ if file == null:
260
+ push_error("Could not write the expected errors to %s: %s" % [path, FileAccess.get_open_error()])
261
+ return
262
+ var _stored: bool = file.store_string(JSON.stringify(expected_by_suite(suites)))
263
+ file.close()