gdharness 1.1.21 → 1.1.22

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.
Files changed (47) hide show
  1. package/build/cli.js +1036 -525
  2. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  3. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  9. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  10. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  13. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
  23. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
  24. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  25. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  26. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  29. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  31. package/build/godot/operations/audio_buses.gd +47 -11
  32. package/build/godot/operations/class_cache.gd +18 -2
  33. package/build/godot/operations/dependencies.gd +128 -17
  34. package/build/godot/operations/file_walk.gd +20 -0
  35. package/build/godot/operations/gdscript_analysis.gd +59 -20
  36. package/build/godot/operations/gdscript_authoring.gd +127 -60
  37. package/build/godot/operations/godot_operations.gd +24 -5
  38. package/build/godot/operations/import_pipeline.gd +179 -19
  39. package/build/godot/operations/logger.gd +2 -2
  40. package/build/godot/operations/plugins.gd +88 -70
  41. package/build/godot/operations/project_config.gd +41 -6
  42. package/build/godot/operations/project_diagnostics.gd +25 -10
  43. package/build/godot/operations/reading.gd +17 -0
  44. package/build/godot/operations/resource_files.gd +60 -17
  45. package/build/godot/operations/serialisation.gd +116 -7
  46. package/build/index.js +949 -435
  47. package/package.json +1 -1
@@ -25,11 +25,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
25
25
 
26
26
  # 1. Project Structure Check
27
27
  if "structure" in check_categories:
28
- var passed: bool = true
29
28
  var details: Array[String] = []
30
29
 
31
30
  if not FileAccess.file_exists("res://project.godot"):
32
- passed = false
33
31
  details.append("Missing project.godot file")
34
32
  deductions += 30
35
33
 
@@ -43,7 +41,7 @@ func get_project_health(params: Dictionary) -> Dictionary:
43
41
  details.append("Consider organizing with directories: " + ", ".join(missing_dirs))
44
42
  deductions += 2 * missing_dirs.size()
45
43
 
46
- checks["structure"] = {"name": "Project Structure", "passed": passed, "details": details}
44
+ checks["structure"] = {"name": "Project Structure", "passed": details.is_empty(), "details": details}
47
45
 
48
46
  # 2. Resource Check
49
47
  if "resources" in check_categories:
@@ -64,7 +62,10 @@ func get_project_health(params: Dictionary) -> Dictionary:
64
62
  deductions += missing_imports
65
63
 
66
64
  checks["resources"] = {
67
- "name": "Resources", "passed": true, "details": details, "total_resources": resources.size()
65
+ "name": "Resources",
66
+ "passed": details.is_empty(),
67
+ "details": details,
68
+ "total_resources": resources.size()
68
69
  }
69
70
 
70
71
  # 3. Scripts Check
@@ -95,7 +96,10 @@ func get_project_health(params: Dictionary) -> Dictionary:
95
96
  deductions += empty_functions
96
97
 
97
98
  checks["scripts"] = {
98
- "name": "Scripts", "passed": true, "details": details, "total_scripts": script_files.size()
99
+ "name": "Scripts",
100
+ "passed": details.is_empty(),
101
+ "details": details,
102
+ "total_scripts": script_files.size()
99
103
  }
100
104
 
101
105
  # 4. Scenes Check
@@ -108,7 +112,10 @@ func get_project_health(params: Dictionary) -> Dictionary:
108
112
  deductions += 5
109
113
 
110
114
  checks["scenes"] = {
111
- "name": "Scenes", "passed": true, "details": details, "total_scenes": scene_files.size()
115
+ "name": "Scenes",
116
+ "passed": details.is_empty(),
117
+ "details": details,
118
+ "total_scenes": scene_files.size()
112
119
  }
113
120
 
114
121
  # 5. Configuration Check
@@ -119,8 +126,8 @@ func get_project_health(params: Dictionary) -> Dictionary:
119
126
  if main_scene.is_empty():
120
127
  details.append("No main scene configured")
121
128
  deductions += 10
122
- elif not FileAccess.file_exists(main_scene):
123
- details.append("Main scene file does not exist: " + main_scene)
129
+ elif not FileWalk.missing_reason(main_scene).is_empty():
130
+ details.append("Main scene " + FileWalk.missing_reason(main_scene))
124
131
  deductions += 15
125
132
 
126
133
  var project_name: String = str(ProjectSettings.get_setting("application/config/name", ""))
@@ -132,7 +139,7 @@ func get_project_health(params: Dictionary) -> Dictionary:
132
139
  details.append("No export presets configured")
133
140
  deductions += 3
134
141
 
135
- checks["config"] = {"name": "Configuration", "passed": true, "details": details}
142
+ checks["config"] = {"name": "Configuration", "passed": details.is_empty(), "details": details}
136
143
 
137
144
  var score: int = maxi(0, 100 - deductions)
138
145
 
@@ -154,6 +161,14 @@ func get_project_health(params: Dictionary) -> Dictionary:
154
161
  if not FileAccess.file_exists("res://export_presets.cfg"):
155
162
  recommendations.append("Configure export presets for your target platforms")
156
163
 
164
+ # Every detail costs points, so each is an issue; `issues` was always empty beside them, and a
165
+ # category said passed next to the failure it had just found.
166
+ var issues: Array[Dictionary] = []
167
+ for category: String in checks:
168
+ var check: Dictionary = checks[category]
169
+ var found: Array[String] = check["details"]
170
+ for detail: String in found:
171
+ issues.append({"check": category, "detail": detail})
157
172
  return {
158
- "score": score, "grade": grade, "checks": checks, "issues": [], "recommendations": recommendations
173
+ "score": score, "grade": grade, "checks": checks, "issues": issues, "recommendations": recommendations
159
174
  }
@@ -52,6 +52,23 @@ static func as_float(value: Variant, fallback: float = 0.0) -> float:
52
52
  if value is String:
53
53
  var text: String = value
54
54
  return text.to_float()
55
+ if value is Dictionary:
56
+ var tagged: Dictionary = value
57
+ if str(tagged.get("_type", "")) == "float":
58
+ return non_finite(str(tagged.get("value", "")), fallback)
59
+ return fallback
60
+
61
+
62
+ ## The float a non-finite tag names, "inf", "-inf" or "nan", or [param fallback] for anything else.
63
+ ## JSON has no spelling for these, so the serialiser tags them rather than let them arrive as null.
64
+ static func non_finite(word: String, fallback: float = 0.0) -> float:
65
+ match word:
66
+ "inf":
67
+ return INF
68
+ "-inf":
69
+ return -INF
70
+ "nan":
71
+ return NAN
55
72
  return fallback
56
73
 
57
74
 
@@ -1,6 +1,14 @@
1
1
  extends RefCounted
2
2
 
3
3
  const Log = preload("logger.gd")
4
+ const Patterns = preload("patterns.gd")
5
+
6
+ ## Where the engine keeps a file's UID, by what kind of file it is. Measured on 4.7.2: a script or
7
+ ## shader has a `.uid` file beside it, an imported file has it in its `.import`, and a text scene or
8
+ ## resource has it in its own header. `ResourceLoader.get_resource_uid` answers the first two in a
9
+ ## headless run and not the third, so each place is read here rather than asking it.
10
+ const SIDECAR_KINDS: Array[String] = ["gd", "gdshader", "gdshaderinc", "cs"]
11
+ const TEXT_RESOURCE_KINDS: Array[String] = ["tscn", "tres", "escn"]
4
12
 
5
13
  var _log: Log
6
14
 
@@ -9,7 +17,7 @@ func _init(p_log: Log) -> void:
9
17
  _log = p_log
10
18
 
11
19
 
12
- # The UID the engine gave a file, from the .uid sidecar it writes beside scripts and shaders.
20
+ # The UID the engine gave a file, read from wherever that kind of file keeps it.
13
21
  func get_uid(params: Dictionary) -> Dictionary:
14
22
  var file_path: String = str(params.get("resource_path", ""))
15
23
  if file_path.is_empty():
@@ -23,19 +31,54 @@ func get_uid(params: Dictionary) -> Dictionary:
23
31
  if not FileAccess.file_exists(file_path):
24
32
  return _log.failure("File does not exist: " + file_path)
25
33
 
26
- var uid_path: String = file_path + ".uid"
27
- var f: FileAccess = FileAccess.open(uid_path, FileAccess.READ)
28
- if not f:
29
- return {
30
- "file": file_path,
31
- "absolute_path": absolute_path,
32
- "exists": false,
33
- "message": "No .uid sidecar. refresh_uids writes one for every script and shader."
34
- }
35
-
36
- var uid_content: String = f.get_as_text()
37
- f.close()
38
-
39
- return {
40
- "file": file_path, "absolute_path": absolute_path, "uid": uid_content.strip_edges(), "exists": true
41
- }
34
+ var answer: Dictionary = {"file": file_path, "absolute_path": absolute_path}
35
+ var found: Dictionary = uid_of(file_path)
36
+ if found.is_empty():
37
+ answer["exists"] = false
38
+ answer["message"] = _why_none(file_path)
39
+ return answer
40
+ answer["exists"] = true
41
+ answer.merge(found)
42
+ return answer
43
+
44
+
45
+ # The UID and where it was read, or empty when the file has none anywhere the engine keeps one.
46
+ static func uid_of(file_path: String) -> Dictionary:
47
+ var sidecar: String = file_path + ".uid"
48
+ if FileAccess.file_exists(sidecar):
49
+ var text: String = FileAccess.get_file_as_string(sidecar).strip_edges()
50
+ if not text.is_empty():
51
+ return {"uid": text, "from": sidecar}
52
+ var import_file: String = file_path + ".import"
53
+ if FileAccess.file_exists(import_file):
54
+ var config: ConfigFile = ConfigFile.new()
55
+ if config.load(import_file) == OK and config.has_section_key("remap", "uid"):
56
+ return {"uid": str(config.get_value("remap", "uid")), "from": import_file}
57
+ if file_path.get_extension().to_lower() in TEXT_RESOURCE_KINDS:
58
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
59
+ if file != null:
60
+ var header: String = file.get_line()
61
+ file.close()
62
+ var matched: RegExMatch = Patterns.compiled('\\buid="(uid://[^"]+)"').search(header)
63
+ if matched != null:
64
+ return {"uid": matched.get_string(1), "from": "the file's header"}
65
+ var id: int = ResourceLoader.get_resource_uid(file_path)
66
+ if id != ResourceUID.INVALID_ID:
67
+ return {"uid": ResourceUID.id_to_text(id), "from": "the engine"}
68
+ return {}
69
+
70
+
71
+ # Why [param file_path] has no UID, and what gives it one, which depends on what kind of file it is:
72
+ # refresh_uids writes a .uid for scripts and shaders only, and was named for every file.
73
+ func _why_none(file_path: String) -> String:
74
+ var kind: String = file_path.get_extension().to_lower()
75
+ if kind in SIDECAR_KINDS:
76
+ return "No .uid file beside it. refresh_uids writes one for every script and shader."
77
+ if kind in TEXT_RESOURCE_KINDS:
78
+ return (
79
+ "Its header carries no uid. The editor writes one when it saves the file; refresh_uids does not,"
80
+ + " and a headless save does not either."
81
+ )
82
+ if ResourceLoader.exists(file_path):
83
+ return "The engine keeps no UID for it."
84
+ return "It has not been imported, and the import is what gives it a UID: project_import reimport imports it."
@@ -53,6 +53,9 @@ const LIST_TYPES: Array[int] = [
53
53
  # type it wanted.
54
54
  const SERIALISERS: Dictionary = {
55
55
  TYPE_NIL: "_serialize_nil",
56
+ TYPE_FLOAT: "_serialize_float",
57
+ TYPE_PACKED_FLOAT32_ARRAY: "_serialize_floats",
58
+ TYPE_PACKED_FLOAT64_ARRAY: "_serialize_floats",
56
59
  TYPE_VECTOR2: "_serialize_vector2",
57
60
  TYPE_VECTOR2I: "_serialize_vector2i",
58
61
  TYPE_VECTOR3: "_serialize_vector3",
@@ -105,6 +108,8 @@ const BUILDERS: Dictionary = {
105
108
  "Color": "_build_color",
106
109
  "NodePath": "_build_node_path",
107
110
  "PackedByteArray": "_build_bytes",
111
+ "float": "_build_float",
112
+ "Dictionary": "_build_dictionary",
108
113
  }
109
114
 
110
115
  # What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
@@ -130,6 +135,8 @@ const REQUIRED: Dictionary = {
130
135
  "Color": [["r", "g", "b"]],
131
136
  "NodePath": [["path"]],
132
137
  "PackedByteArray": [["base64"]],
138
+ "float": [["value"]],
139
+ "Dictionary": [["entries"]],
133
140
  }
134
141
 
135
142
 
@@ -145,7 +152,58 @@ func serialize_value(value: Variant) -> Variant:
145
152
  if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
146
153
  return null
147
154
  var serialiser: String = SERIALISERS.get(typeof(value), "")
148
- return call(serialiser, value) if not serialiser.is_empty() else value
155
+ if serialiser.is_empty():
156
+ return value
157
+ var carried: Variant = call(serialiser, value)
158
+ # The fixed shapes carry their components as bare floats, so an infinite one in a Vector2 is
159
+ # tagged here; containers reach their members through this function already.
160
+ return carried if typeof(value) in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried)
161
+
162
+
163
+ # [param carried] with every non-finite float in it tagged. JSON has no spelling for infinity or
164
+ # NaN: the engine writes 1e99999 and null, and the server, reading 1e99999 as Infinity, writes it
165
+ # back out as null, so a property holding INF was answered as holding nothing.
166
+ func _finite(carried: Variant) -> Variant:
167
+ if carried is float:
168
+ var number: float = carried
169
+ return _serialize_float(number)
170
+ if carried is Dictionary:
171
+ var fields: Dictionary = carried
172
+ var tagged: Dictionary = {}
173
+ for key: Variant in fields:
174
+ tagged[key] = _finite(fields[key])
175
+ return tagged
176
+ if carried is Array:
177
+ var items: Array = carried
178
+ var tagged_items: Array = []
179
+ for item: Variant in items:
180
+ tagged_items.append(_finite(item))
181
+ return tagged_items
182
+ return carried
183
+
184
+
185
+ func _serialize_float(value: float) -> Variant:
186
+ if is_finite(value):
187
+ return value
188
+ return {"_type": "float", "value": "nan" if is_nan(value) else ("inf" if value > 0 else "-inf")}
189
+
190
+
191
+ # A packed float array stays the JSON list it is unless something in it has no JSON spelling.
192
+ func _serialize_floats(value: Variant) -> Variant:
193
+ var numbers: Array = []
194
+ if value is PackedFloat32Array:
195
+ var single: PackedFloat32Array = value
196
+ numbers = Array(single)
197
+ else:
198
+ var double: PackedFloat64Array = value
199
+ numbers = Array(double)
200
+ for number: float in numbers:
201
+ if not is_finite(number):
202
+ var items: Array = []
203
+ for each: float in numbers:
204
+ items.append(_serialize_float(each))
205
+ return items
206
+ return value
149
207
 
150
208
 
151
209
  # Rebuilds a Godot value from the shape serialize_value gave it.
@@ -181,9 +239,27 @@ func deserialize_value(value: Variant) -> Variant:
181
239
  var needed: Array = REQUIRED.get(tag, [])
182
240
  if builder.is_empty() or not _carries(fields, needed):
183
241
  return fields
242
+ if tag == "float" and str(fields["value"]) not in ["inf", "-inf", "nan"]:
243
+ return fields
244
+ if tag == "Dictionary" and not pairs(fields["entries"]):
245
+ return fields
184
246
  return call(builder, fields)
185
247
 
186
248
 
249
+ ## Whether [param entries] is a list of [key, value] pairs, as a Dictionary tag carries them.
250
+ static func pairs(entries: Variant) -> bool:
251
+ if not entries is Array:
252
+ return false
253
+ var listed: Array = entries
254
+ for entry: Variant in listed:
255
+ if not entry is Array:
256
+ return false
257
+ var pair: Array = entry
258
+ if pair.size() != 2:
259
+ return false
260
+ return true
261
+
262
+
187
263
  ## Whether two values can be compared without the comparison itself failing.
188
264
  ##
189
265
  ## GDScript's `==` is not total. An Object against a String is a hard error rather than false, and
@@ -193,16 +269,19 @@ func deserialize_value(value: Variant) -> Variant:
193
269
  ## nothing anywhere naming the argument that did it. A wrong argument costs a refusal, never the
194
270
  ## session's game.
195
271
  ##
196
- ## Only an object is special. Every other pair the engine answers, false where they differ, and
197
- ## refusing those would refuse calls that work: an int against a float is the same number to a
198
- ## caller waiting for 1 on a property holding 1.0.
272
+ ## Measured on 4.7.2 over every pair of built-in types: two values of different types compare only
273
+ ## as an int against a float, a String against a StringName, or anything against null. Every other
274
+ ## pair is that error, a word against a number as much as an object against a word, and so is a bool
275
+ ## against an int and a NodePath against a String. An int against a float is kept because it is the
276
+ ## same number to a caller waiting for 1 on a property holding 1.0.
199
277
  static func comparable(one: Variant, other: Variant) -> bool:
200
278
  var left: int = typeof(one)
201
279
  var right: int = typeof(other)
202
- if left != TYPE_OBJECT and right != TYPE_OBJECT:
280
+ if left == right or left == TYPE_NIL or right == TYPE_NIL:
203
281
  return true
204
- # An object compares with another object and with null. Anything else is the error above.
205
- return (left == TYPE_OBJECT or left == TYPE_NIL) and (right == TYPE_OBJECT or right == TYPE_NIL)
282
+ var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
283
+ var texts: Array[int] = [TYPE_STRING, TYPE_STRING_NAME]
284
+ return (left in numbers and right in numbers) or (left in texts and right in texts)
206
285
 
207
286
 
208
287
  ## Whether [param value] can be given to something declared as [param type] without damage.
@@ -400,7 +479,16 @@ func _serialize_signal(value: Signal) -> Dictionary:
400
479
  return described
401
480
 
402
481
 
482
+ ## A dictionary keyed by text as itself, and any other as its entries. JSON keys are text, so an
483
+ ## int or Vector2 key was answered as its text, and a dictionary read and written back had its keys
484
+ ## replaced by words: `{1: "a"}` came back as `{"1": "a"}`.
403
485
  func _serialize_dictionary(value: Dictionary) -> Dictionary:
486
+ var texts: bool = value.keys().all(func(key: Variant) -> bool: return key is String or key is StringName)
487
+ if not texts:
488
+ var entries: Array = []
489
+ for key: Variant in value:
490
+ entries.append([serialize_value(key), serialize_value(value[key])])
491
+ return {"_type": "Dictionary", "entries": entries}
404
492
  var serialised: Dictionary = {}
405
493
  for key: Variant in value:
406
494
  serialised[str(key)] = serialize_value(value[key])
@@ -621,6 +709,27 @@ func _build_bytes(fields: Dictionary) -> PackedByteArray:
621
709
  return Marshalls.base64_to_raw(str(fields.get("base64", "")))
622
710
 
623
711
 
712
+ func _build_float(fields: Dictionary) -> float:
713
+ return Read.as_float(fields)
714
+
715
+
716
+ ## The entries of a dictionary whose keys are not all text. JSON carries every number as a float,
717
+ ## and a dictionary keys 1.0 apart from 1, so a whole-number key is built as an int: the key a
718
+ ## game uses far more often, and one JSON cannot tell from the float.
719
+ func _build_dictionary(fields: Dictionary) -> Dictionary:
720
+ var built: Dictionary = {}
721
+ var entries: Array = fields["entries"]
722
+ for entry: Variant in entries:
723
+ var pair: Array = entry
724
+ var key: Variant = deserialize_value(pair[0])
725
+ if key is float:
726
+ var number: float = key
727
+ if is_finite(number) and number == floorf(number):
728
+ key = int(number)
729
+ built[key] = deserialize_value(pair[1])
730
+ return built
731
+
732
+
624
733
  ## One component of a compound value, as the type its constructor wants.
625
734
  ##
626
735
  ## Their own functions because a constructor refuses a Variant under `unsafe_call_argument`, and