gdharness 1.3.8 → 1.3.10

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 (78) hide show
  1. package/build/cli.js +4668 -3229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -0,0 +1,222 @@
1
+ extends RefCounted
2
+
3
+ ## The signal connections the project's scenes hold that cannot work: a method the node it calls
4
+ ## does not have, which fails on every emission, or a signal the node it leaves does not have, which
5
+ ## the engine drops as the scene loads. Read from the text of each scene and script, so nothing is
6
+ ## loaded and no script runs: a scene loaded to be checked loads every texture and mesh it uses, and
7
+ ## a script loaded that does not compile prints its errors here as well as where the scripts check
8
+ ## names it.
9
+ ##
10
+ ## A node whose type or script the text does not settle (a built-in script, a C# one, a child of an
11
+ ## instance the scene does not list) is not judged, since nothing here could say it is wrong.
12
+
13
+ const GdhDependencies = preload("dependencies.gd")
14
+ const GdhFileWalk = preload("file_walk.gd")
15
+ const GdhLog = preload("logger.gd")
16
+
17
+ const EXT_RESOURCE: String = '^\\[ext_resource [^\\]]*?path="([^"]+)"[^\\]]*? id="([^"]+)"'
18
+ const NODE: String = "^\\[node ([^\\]]*)\\]"
19
+ const CONNECTION: String = "^\\[connection ([^\\]]*)\\]"
20
+ const ATTRIBUTE: String = '(\\w+)="([^"]*)"'
21
+ const INSTANCE: String = 'instance=ExtResource\\(\\s*"([^"]+)"\\s*\\)'
22
+ const SCRIPT_LINE: String = '^script = (Ext|Sub)Resource\\(\\s*"([^"]+)"\\s*\\)'
23
+ const FUNC_LINE: String = "^(?:static\\s+)?func\\s+(\\w+)"
24
+ const SIGNAL_LINE: String = "^signal\\s+(\\w+)"
25
+ const EXTENDS_LINE: String = "^(?:@\\w+\\s+)*(?:class_name\\s+\\w+\\s+)?extends\\s+([^\\s:#]+)"
26
+
27
+ var _files: GdhFileWalk = GdhFileWalk.new()
28
+ ## What each script declares, by path: {class, methods, signals}, or {} when it cannot be read.
29
+ var _scripts: Dictionary = {}
30
+ ## What each scene's root is, by path: {class, script}, as a node instancing it is.
31
+ var _roots: Dictionary = {}
32
+ ## The class each class_name names, read from the scripts rather than the class cache, which only a
33
+ ## scan rewrites.
34
+ var _globals: Dictionary = {}
35
+
36
+
37
+ func _init(p_log: GdhLog) -> void:
38
+ _globals = GdhDependencies.new(p_log).classes_declared()
39
+
40
+
41
+ ## Every connection that cannot work, as {scene, from, signal, to, method, missing}, where missing is
42
+ ## "method" or "signal".
43
+ func broken() -> Array[Dictionary]:
44
+ var found: Array[Dictionary] = []
45
+ for scene: String in _files.find_files_with_extensions("res://", ["tscn"]):
46
+ var content: String = FileAccess.get_file_as_string(scene)
47
+ var nodes: Dictionary = _nodes_of(content, scene)
48
+ for attributes: Dictionary in _matches(CONNECTION, content):
49
+ var from: String = str(attributes.get("from", ""))
50
+ var to: String = str(attributes.get("to", ""))
51
+ var signal_name: String = str(attributes.get("signal", ""))
52
+ var method: String = str(attributes.get("method", ""))
53
+ # The root by its name, since its path, ".", runs into the member's dot.
54
+ var root: Dictionary = nodes.get(".", {})
55
+ var root_name: String = str(root.get("name", "."))
56
+ var entry: Dictionary = {
57
+ "scene": scene,
58
+ "from": root_name if from == "." else from,
59
+ "signal": signal_name,
60
+ "to": root_name if to == "." else to,
61
+ "method": method,
62
+ }
63
+ if _lacks(nodes, from, signal_name, "signals"):
64
+ entry["missing"] = "signal"
65
+ found.append(entry)
66
+ elif _lacks(nodes, to, method, "methods"):
67
+ entry["missing"] = "method"
68
+ found.append(entry)
69
+ return found
70
+
71
+
72
+ ## One line for [param entry], one of [method broken].
73
+ static func broken_line(entry: Dictionary) -> String:
74
+ var connected: String = (
75
+ "%s connects %s.%s to %s.%s"
76
+ % [entry["scene"], entry["from"], entry["signal"], entry["to"], entry["method"]]
77
+ )
78
+ if entry["missing"] == "signal":
79
+ return connected + ", a signal that node does not have, so the engine drops it as the scene loads."
80
+ return connected + ", a method that node does not have, so every emission fails to call it."
81
+
82
+
83
+ ## Each node [param content] declares, by its path from the root, as {class, script}, either of
84
+ ## which may be "" when the text does not say.
85
+ func _nodes_of(content: String, scene: String) -> Dictionary:
86
+ var resources: Dictionary = {}
87
+ var expression: RegEx = RegEx.create_from_string(EXT_RESOURCE)
88
+ for line: String in content.split("\n"):
89
+ var matched: RegExMatch = expression.search(line)
90
+ if matched != null:
91
+ resources[matched.get_string(2)] = matched.get_string(1)
92
+ var nodes: Dictionary = {}
93
+ var node_line: RegEx = RegEx.create_from_string(NODE)
94
+ var script_line: RegEx = RegEx.create_from_string(SCRIPT_LINE)
95
+ var instance: RegEx = RegEx.create_from_string(INSTANCE)
96
+ var current: String = ""
97
+ for line: String in content.split("\n"):
98
+ var heading: RegExMatch = node_line.search(line)
99
+ if heading != null:
100
+ var attributes: Dictionary = _attributes(heading.get_string(1))
101
+ var name: String = str(attributes.get("name", ""))
102
+ var parent: Variant = attributes.get("parent")
103
+ current = "." if parent == null else (name if parent == "." else "%s/%s" % [parent, name])
104
+ var node: Dictionary = {"class": str(attributes.get("type", "")), "script": "", "name": name}
105
+ var instanced: RegExMatch = instance.search(heading.get_string(1))
106
+ if instanced != null:
107
+ var root: Dictionary = _root_of(str(resources.get(instanced.get_string(1), "")), scene)
108
+ node = root.duplicate()
109
+ node["name"] = name
110
+ nodes[current] = node
111
+ continue
112
+ if line.begins_with("["):
113
+ current = ""
114
+ continue
115
+ var scripted: RegExMatch = script_line.search(line)
116
+ if scripted != null and nodes.has(current):
117
+ var node: Dictionary = nodes[current]
118
+ # A built-in script lives in the scene, where this does not read it, so its node is one
119
+ # nothing here can judge, which a path that is no script file says.
120
+ var external: bool = scripted.get_string(1) == "Ext"
121
+ node["script"] = str(resources.get(scripted.get_string(2), "?")) if external else "built-in"
122
+ return nodes
123
+
124
+
125
+ ## What the root of the scene at [param path] is, as [method _nodes_of] gives a node, or a node that
126
+ ## says nothing when it cannot be read or instances [param from], which would never end.
127
+ func _root_of(path: String, from: String) -> Dictionary:
128
+ if _roots.has(path):
129
+ return _roots[path]
130
+ _roots[path] = {"class": "", "script": ""}
131
+ if path.is_empty() or path == from or not path.ends_with(".tscn") or not FileAccess.file_exists(path):
132
+ return _roots[path]
133
+ var nodes: Dictionary = _nodes_of(FileAccess.get_file_as_string(path), path)
134
+ _roots[path] = nodes.get(".", {"class": "", "script": ""})
135
+ return _roots[path]
136
+
137
+
138
+ ## Whether the node at [param path] among [param nodes] surely has no [param member] among its
139
+ ## [param kind], "methods" or "signals": false whenever what it is cannot be read.
140
+ func _lacks(nodes: Dictionary, path: String, member: String, kind: String) -> bool:
141
+ if not nodes.has(path):
142
+ return false
143
+ var node: Dictionary = nodes[path]
144
+ var engine_class: String = str(node["class"])
145
+ var script: String = str(node["script"])
146
+ if not script.is_empty():
147
+ var declared: Dictionary = _script(script, {})
148
+ if declared.is_empty():
149
+ return false
150
+ var declared_members: Dictionary = declared[kind]
151
+ if declared_members.has(member):
152
+ return false
153
+ engine_class = str(declared["class"])
154
+ if engine_class.is_empty() or not ClassDB.class_exists(engine_class):
155
+ return false
156
+ if kind == "signals":
157
+ return not ClassDB.class_has_signal(engine_class, member)
158
+ return not ClassDB.class_has_method(engine_class, member)
159
+
160
+
161
+ ## What the script at [param path] declares with every script it extends, as {class, methods,
162
+ ## signals}, where class is the engine class at the bottom; {} for one this cannot read through.
163
+ ## [param seen] stops a loop of scripts extending each other.
164
+ func _script(path: String, seen: Dictionary) -> Dictionary:
165
+ if _scripts.has(path):
166
+ return _scripts[path]
167
+ if seen.has(path) or not path.ends_with(".gd") or not FileAccess.file_exists(path):
168
+ return {}
169
+ seen[path] = true
170
+ var methods: Dictionary = {}
171
+ var signals: Dictionary = {}
172
+ var base: String = "RefCounted"
173
+ var func_line: RegEx = RegEx.create_from_string(FUNC_LINE)
174
+ var signal_line: RegEx = RegEx.create_from_string(SIGNAL_LINE)
175
+ var extends_line: RegEx = RegEx.create_from_string(EXTENDS_LINE)
176
+ for line: String in FileAccess.get_file_as_string(path).split("\n"):
177
+ var named: RegExMatch = func_line.search(line)
178
+ if named != null:
179
+ methods[named.get_string(1)] = true
180
+ var sent: RegExMatch = signal_line.search(line)
181
+ if sent != null:
182
+ signals[sent.get_string(1)] = true
183
+ var extended: RegExMatch = extends_line.search(line)
184
+ if extended != null:
185
+ base = extended.get_string(1)
186
+ var engine_class: String = base
187
+ var base_path: String = ""
188
+ if base.begins_with('"') or base.begins_with("'"):
189
+ base_path = base.substr(1, base.length() - 2)
190
+ if base_path.is_relative_path():
191
+ base_path = path.get_base_dir().path_join(base_path)
192
+ elif _globals.has(base):
193
+ base_path = _globals[base]
194
+ if not base_path.is_empty():
195
+ var inherited: Dictionary = _script(base_path, seen)
196
+ if inherited.is_empty():
197
+ return {}
198
+ var inherited_methods: Dictionary = inherited["methods"]
199
+ var inherited_signals: Dictionary = inherited["signals"]
200
+ methods.merge(inherited_methods)
201
+ signals.merge(inherited_signals)
202
+ engine_class = str(inherited["class"])
203
+ _scripts[path] = {"class": engine_class, "methods": methods, "signals": signals}
204
+ return _scripts[path]
205
+
206
+
207
+ ## The attributes of every line of [param content] [param pattern] matches, one Dictionary each.
208
+ func _matches(pattern: String, content: String) -> Array[Dictionary]:
209
+ var expression: RegEx = RegEx.create_from_string(pattern)
210
+ var all: Array[Dictionary] = []
211
+ for line: String in content.split("\n"):
212
+ var matched: RegExMatch = expression.search(line)
213
+ if matched != null:
214
+ all.append(_attributes(matched.get_string(1)))
215
+ return all
216
+
217
+
218
+ static func _attributes(text: String) -> Dictionary:
219
+ var attributes: Dictionary = {}
220
+ for matched: RegExMatch in RegEx.create_from_string(ATTRIBUTE).search_all(text):
221
+ attributes[matched.get_string(1)] = matched.get_string(2)
222
+ return attributes
@@ -142,28 +142,29 @@ func serialize_value(value: Variant) -> Variant:
142
142
  return value
143
143
  var carried: Variant = call(serialiser, value)
144
144
  # The fixed shapes carry their components as bare floats, so an infinite one in a Vector2 is
145
- # tagged here; containers reach their members through this function already.
146
- return carried if typeof(value) in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried)
145
+ # tagged here, and a 32-bit one shortened; containers reach their members through this already.
146
+ var type: int = typeof(value)
147
+ return carried if type in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried, GdhRead.holds_singles(type))
147
148
 
148
149
 
149
150
  # [param carried] with every non-finite float in it tagged. JSON has no spelling for infinity or
150
151
  # NaN: the engine writes 1e99999 and null, and the server, reading 1e99999 as Infinity, writes it
151
152
  # back out as null, so a property holding INF was answered as holding nothing.
152
- func _finite(carried: Variant) -> Variant:
153
+ func _finite(carried: Variant, single: bool = false) -> Variant:
153
154
  if carried is float:
154
155
  var number: float = carried
155
- return _serialize_float(number)
156
+ return _serialize_float(GdhRead.single(number) if single else number)
156
157
  if carried is Dictionary:
157
158
  var fields: Dictionary = carried
158
159
  var tagged: Dictionary = {}
159
160
  for key: Variant in fields:
160
- tagged[key] = _finite(fields[key])
161
+ tagged[key] = _finite(fields[key], single)
161
162
  return tagged
162
163
  if carried is Array:
163
164
  var items: Array = carried
164
165
  var tagged_items: Array = []
165
166
  for item: Variant in items:
166
- tagged_items.append(_finite(item))
167
+ tagged_items.append(_finite(item, single))
167
168
  return tagged_items
168
169
  return carried
169
170
 
@@ -189,7 +190,7 @@ func _serialize_floats(value: Variant) -> Variant:
189
190
  for each: float in numbers:
190
191
  items.append(_serialize_float(each))
191
192
  return items
192
- return value
193
+ return numbers if value is PackedFloat32Array else value
193
194
 
194
195
 
195
196
  # Rebuilds a Godot value from the shape serialize_value gave it.
@@ -222,8 +223,7 @@ func deserialize_value(value: Variant) -> Variant:
222
223
  # under the target project's warning levels, so one such project lost every headless call.
223
224
  var tag: String = str(fields["_type"])
224
225
  var builder: String = BUILDERS.get(tag, "")
225
- var spare: Array = GdhShapes.spare(fields, tag)
226
- if builder.is_empty() or not spare.any(func(left: Array) -> bool: return left.is_empty()):
226
+ if builder.is_empty() or not GdhShapes.buildable(fields, tag):
227
227
  return fields
228
228
  if tag == "float" and str(fields["value"]) not in ["inf", "-inf", "nan"]:
229
229
  return fields
@@ -276,24 +276,7 @@ static func components_of(value: Variant) -> PackedStringArray:
276
276
  TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
277
277
  return PackedStringArray(["x", "y", "z", "w"])
278
278
  TYPE_COLOR:
279
- return PackedStringArray(
280
- [
281
- "r",
282
- "g",
283
- "b",
284
- "a",
285
- "r8",
286
- "g8",
287
- "b8",
288
- "a8",
289
- "h",
290
- "s",
291
- "v",
292
- "ok_hsl_h",
293
- "ok_hsl_s",
294
- "ok_hsl_l"
295
- ]
296
- )
279
+ return "r g b a r8 g8 b8 a8 h s v ok_hsl_h ok_hsl_s ok_hsl_l".split(" ")
297
280
  TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
298
281
  return PackedStringArray(["position", "size", "end"])
299
282
  TYPE_PLANE:
@@ -404,7 +387,8 @@ func fitted(value: Variant, type: int) -> Variant:
404
387
  return built
405
388
 
406
389
  if rebuilt is Array and LIST_TYPES.has(type):
407
- return type_convert(rebuilt, type)
390
+ var items: Array = value if value is Array else rebuilt
391
+ return GdhShapes.packed_from(items, type, fitted)
408
392
 
409
393
  # The inspector and every colour picker give a colour as its html code. Anything else stays the
410
394
  # text it is, for the refusal to name.
@@ -704,7 +688,13 @@ func _describe_built_in(resource: Resource) -> Dictionary:
704
688
  var script: Script = null
705
689
  if attached is Script:
706
690
  script = attached
707
- described["script"] = serialize_value(script)
691
+ # By the class its script declares, which a write takes as the _type and which says what it
692
+ # is: as Resource with the script beside it, one answer named a Shop's built-in item that way
693
+ # and the file item beside it as an Item.
694
+ if script.get_global_name().is_empty():
695
+ described["script"] = serialize_value(script)
696
+ else:
697
+ described["_type"] = str(script.get_global_name())
708
698
  var fresh: Object = _fresh(script)
709
699
  for property: Dictionary in resource.get_property_list():
710
700
  var usage: int = property.get("usage", 0)
@@ -0,0 +1,121 @@
1
+ extends RefCounted
2
+
3
+ ## Project settings read the same way headless and from the open editor, because the two are one
4
+ ## question asked of two places: a field one of them left out read as the editor and the file
5
+ ## disagreeing about something they agree about.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+ const GdhSerialisation = preload("serialisation.gd")
9
+
10
+
11
+ ## One setting: whether the engine knows it, its value, whether that is the engine's default, and
12
+ ## the feature overrides that replace it in some builds.
13
+ static func one(setting_path: String, values: GdhSerialisation) -> Dictionary:
14
+ var result: Dictionary = {
15
+ "setting_path": setting_path, "exists": ProjectSettings.has_setting(setting_path)
16
+ }
17
+
18
+ if result["exists"]:
19
+ var value: Variant = ProjectSettings.get_setting(setting_path)
20
+ result["value"] = values.serialize_value(value)
21
+ # A setting the engine registers exists whether or not the project sets it, so exists did
22
+ # not say whether project.godot has a line for it. At its default it has none: the engine
23
+ # drops such a key when it saves.
24
+ var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
25
+ if default_value != null:
26
+ result["at_default"] = value == default_value
27
+ else:
28
+ result["value"] = null
29
+ result["message"] = "Setting does not exist"
30
+
31
+ # An override exists only while project.godot sets it, so one the file does not set read as a
32
+ # setting nobody had heard of, when a build with that feature reads its base setting instead.
33
+ var base: String = override_base(setting_path)
34
+ if not base.is_empty():
35
+ result["override_of"] = base
36
+ if not result["exists"] and ProjectSettings.has_setting(base):
37
+ result["holds"] = values.serialize_value(ProjectSettings.get_setting(base))
38
+ result["message"] = overridden_by_none(setting_path, values)
39
+
40
+ # The value under this name is not the one every build reads: `name.windows` replaces it on
41
+ # Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
42
+ # their game showed.
43
+ var overrides: Dictionary = overrides_of(setting_path, values)
44
+ if not overrides.is_empty():
45
+ result["overrides"] = overrides
46
+
47
+ return result
48
+
49
+
50
+ ## Every setting whose name starts with [param prefix], with the type the engine registers for
51
+ ## each.
52
+ ##
53
+ ## The type is here because it is the half a name hides. A setting sitting among a family of levels
54
+ ## can be a bool, and a caller that assumes otherwise writes a level over it and sees nothing go
55
+ ## wrong: 2 is as true as true is.
56
+ static func under(prefix: String, values: GdhSerialisation) -> Dictionary:
57
+ var found: Array[Dictionary] = []
58
+ for property: Dictionary in ProjectSettings.get_property_list():
59
+ var name: String = str(property.get("name", ""))
60
+ if not name.begins_with(prefix):
61
+ continue
62
+ var kind: int = GdhRead.as_int(property.get("type", TYPE_NIL), TYPE_NIL)
63
+ var value: Variant = ProjectSettings.get_setting(name)
64
+ var described: Dictionary = {
65
+ "setting": name,
66
+ "type": type_string(kind),
67
+ "value": values.serialize_value(value),
68
+ }
69
+ # As a single get says it, so a family read can be told apart into what the project sets
70
+ # and the engine's defaults: "input/" is ninety actions the engine ships and the project's few.
71
+ var default_value: Variant = ProjectSettings.property_get_revert(name)
72
+ if default_value != null:
73
+ described["at_default"] = value == default_value
74
+ var base: String = override_base(name)
75
+ if not base.is_empty():
76
+ described["override_of"] = base
77
+ found.append(described)
78
+
79
+ found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["setting"] < b["setting"])
80
+ return {"prefix": prefix, "count": found.size(), "settings": found}
81
+
82
+
83
+ ## The setting [param setting_path] overrides for builds with one feature, as
84
+ ## `application/run/max_fps.mobile` does `application/run/max_fps`, or "" for a plain setting.
85
+ static func override_base(setting_path: String) -> String:
86
+ var dot: int = setting_path.find(".", setting_path.rfind("/") + 1)
87
+ return "" if dot == -1 else setting_path.substr(0, dot)
88
+
89
+
90
+ ## The overrides project.godot sets for [param setting_path], by feature.
91
+ static func overrides_of(setting_path: String, values: GdhSerialisation) -> Dictionary:
92
+ var overrides: Dictionary = {}
93
+ for info: Dictionary in ProjectSettings.get_property_list():
94
+ var name: String = str(info.get("name", ""))
95
+ if name.begins_with(setting_path + "."):
96
+ overrides[name.substr(setting_path.length() + 1)] = values.serialize_value(
97
+ ProjectSettings.get_setting(name)
98
+ )
99
+ return overrides
100
+
101
+
102
+ ## What a build with the feature of [param setting_path], an override project.godot does not set,
103
+ ## reads in its place.
104
+ static func overridden_by_none(setting_path: String, values: GdhSerialisation) -> String:
105
+ var base: String = override_base(setting_path)
106
+ var feature: String = setting_path.substr(base.length() + 1)
107
+ var said: String = (
108
+ (
109
+ "project.godot sets no %s override of %s, so a build with the %s feature reads %s, "
110
+ + "the value under holds"
111
+ )
112
+ % [feature, base, feature, base]
113
+ )
114
+ var others: Array[String] = []
115
+ for name: String in overrides_of(base, values).keys():
116
+ if name != feature:
117
+ others.append(name)
118
+ if others.is_empty():
119
+ return said + "."
120
+ others.sort()
121
+ return said + (", unless it also has a feature with an override: %s." % ", ".join(others))
@@ -5,6 +5,22 @@ extends RefCounted
5
5
  ##
6
6
  ## This file is the one beside the operations, and `bun run sync:gd` copies it into each addon.
7
7
 
8
+ const GdhRead = preload("reading.gd")
9
+
10
+ ## What each packed array holds, one value of which every element is fitted to.
11
+ const PACKED_ELEMENTS: Dictionary = {
12
+ TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
13
+ TYPE_PACKED_INT32_ARRAY: TYPE_INT,
14
+ TYPE_PACKED_INT64_ARRAY: TYPE_INT,
15
+ TYPE_PACKED_FLOAT32_ARRAY: TYPE_FLOAT,
16
+ TYPE_PACKED_FLOAT64_ARRAY: TYPE_FLOAT,
17
+ TYPE_PACKED_STRING_ARRAY: TYPE_STRING,
18
+ TYPE_PACKED_VECTOR2_ARRAY: TYPE_VECTOR2,
19
+ TYPE_PACKED_VECTOR3_ARRAY: TYPE_VECTOR3,
20
+ TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
21
+ TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
22
+ }
23
+
8
24
  # What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
9
25
  # tag somebody wrote by mistake or wrote for a different shape, and building one out of defaults
10
26
  # answers with a Transform2D of zeros where the caller meant something else entirely. Unbuilt, it
@@ -33,6 +49,21 @@ const REQUIRED: Dictionary = {
33
49
  "RID": [["id"]],
34
50
  "Dictionary": [["entries"]],
35
51
  }
52
+ ## The fields each type reads as numbers. A builder reads a word there as 0, so {"x": 0, "y": "far"}
53
+ ## was written as (0, 0) and answered as written.
54
+ const NUMBER_FIELDS: Dictionary = {
55
+ "Vector2": ["x", "y"],
56
+ "Vector2i": ["x", "y"],
57
+ "Vector3": ["x", "y", "z"],
58
+ "Vector3i": ["x", "y", "z"],
59
+ "Vector4": ["x", "y", "z", "w"],
60
+ "Vector4i": ["x", "y", "z", "w"],
61
+ "Rect2": ["x", "y", "width", "height"],
62
+ "Rect2i": ["x", "y", "width", "height"],
63
+ "Plane": ["d"],
64
+ "Quaternion": ["x", "y", "z", "w"],
65
+ "Color": ["r", "g", "b", "a"],
66
+ }
36
67
  ## The fields a builder reads when they are there and does without when not.
37
68
  const OPTIONAL: Dictionary = {
38
69
  "Color": ["a"],
@@ -77,6 +108,86 @@ static func spare(fields: Dictionary, tag: String) -> Array:
77
108
  return lists
78
109
 
79
110
 
111
+ ## Whether a [param tag] built from [param fields] reads every one of them, and each it reads as a
112
+ ## number holds one.
113
+ static func buildable(fields: Dictionary, tag: String) -> bool:
114
+ return (
115
+ spare(fields, tag).any(func(left: Array) -> bool: return left.is_empty())
116
+ and not_a_number(fields, tag).is_empty()
117
+ )
118
+
119
+
120
+ ## The first field of [param fields] that a [param tag] reads as a number and that holds no number,
121
+ ## or "" when there is none. Digits in quotes are a number, as a write takes them for a float.
122
+ static func not_a_number(fields: Dictionary, tag: String) -> String:
123
+ for key: String in NUMBER_FIELDS.get(tag, []):
124
+ if fields.has(key) and not _is_number(fields[key]):
125
+ return key
126
+ return ""
127
+
128
+
129
+ ## Whether every item of [param items] is a number, for a list read by its places as a vector.
130
+ static func all_numbers(items: Array) -> bool:
131
+ return items.all(_is_number)
132
+
133
+
134
+ # A float JSON has no number for, infinity or NaN, crosses tagged as {"_type": "float", "value":
135
+ # "inf"}, and is a number.
136
+ static func _is_number(held: Variant) -> bool:
137
+ if held is int or held is float:
138
+ return true
139
+ if held is String:
140
+ return str(held).is_valid_float()
141
+ if held is Dictionary:
142
+ var tagged: Dictionary = held
143
+ return str(tagged.get("_type", "")) in ["float", "int"] and tagged.has("value")
144
+ return false
145
+
146
+
147
+ ## Whether [param value] holds a number with a fractional part where an integer is wanted, in a
148
+ ## list or a dictionary as much as bare. The conversion would cut 2.7 to 2 without a word, and a
149
+ ## dictionary key arrives as the text "2.7", which reads as the same number.
150
+ static func fraction(value: Variant, type: int, elements: Array = []) -> bool:
151
+ if type == TYPE_INT:
152
+ var number: Variant = value
153
+ if typeof(value) == TYPE_STRING:
154
+ number = GdhRead.json_or_null(str(value))
155
+ if typeof(number) != TYPE_FLOAT:
156
+ return false
157
+ var whole: float = number
158
+ return is_finite(whole) and whole != floorf(whole)
159
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
160
+ if type == TYPE_ARRAY and not elements.is_empty():
161
+ element = GdhRead.as_int(elements[0], TYPE_NIL)
162
+ if value is Array and element != TYPE_NIL:
163
+ var items: Array = value
164
+ return items.any(func(item: Variant) -> bool: return fraction(item, element))
165
+ if type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
166
+ var entries: Dictionary = value
167
+ var key_type: int = GdhRead.as_int(elements[0], TYPE_NIL)
168
+ var value_type: int = GdhRead.as_int(elements[1], TYPE_NIL)
169
+ for key: Variant in entries:
170
+ if fraction(key, key_type) or fraction(entries[key], value_type):
171
+ return true
172
+ return false
173
+
174
+
175
+ ## The packed array [param type] built from [param items], each fitted by [param fit] to what the
176
+ ## array holds, or [param items] as they are when one cannot become that, for the caller to refuse.
177
+ ## Element by element, so a point given as its fields is read as one: converted whole, a list of
178
+ ## {"x": 3, "y": 4} became zero vectors, and the call ran on them.
179
+ static func packed_from(items: Array, type: int, fit: Callable) -> Variant:
180
+ var element: int = PACKED_ELEMENTS[type]
181
+ var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
182
+ var built: Array = []
183
+ for item: Variant in items:
184
+ var one: Variant = fit.call(item, element)
185
+ if typeof(one) != element and not (numbers.has(typeof(one)) and numbers.has(element)):
186
+ return items
187
+ built.append(one)
188
+ return type_convert(built, type)
189
+
190
+
80
191
  ## The sentences a refusal of [param value] where a [param type] goes ends with: which of its keys
81
192
  ## kept it from being read as one, and how one is written. Empty for a type not built from fields.
82
193
  ##
@@ -92,7 +203,23 @@ static func remedy(value: Variant, type: int) -> String:
92
203
  var shape: String = written_as(type)
93
204
  if shape.is_empty():
94
205
  return ""
95
- return _unread(value, type) + " A %s is sent as %s." % [type_string(type), shape]
206
+ return (
207
+ _unread(value, type) + _no_number(value, type) + " A %s is sent as %s." % [type_string(type), shape]
208
+ )
209
+
210
+
211
+ static func _no_number(value: Variant, type: int) -> String:
212
+ var tag: String = type_string(type)
213
+ if value is Dictionary:
214
+ var fields: Dictionary = value
215
+ var key: String = not_a_number(fields, tag)
216
+ if not key.is_empty():
217
+ return " Its %s is %s, which is no number." % [key, JSON.stringify(fields[key])]
218
+ if value is Array and NUMBER_FIELDS.has(tag):
219
+ var items: Array = value
220
+ if not all_numbers(items):
221
+ return " Read by its places, each item is a number."
222
+ return ""
96
223
 
97
224
 
98
225
  static func _unread(value: Variant, type: int) -> String:
@@ -10,8 +10,8 @@ extends SceneTree
10
10
  ## its own and reaches gdUnit4 through the project's global classes. Besides the file of suites it
11
11
  ## finds each case by its id in a dictionary ([GdhIndexedSession]), can compile the suites together
12
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]).
13
+ ## not read ([constant REPORTS_OFF]), and writes the errors and warnings each suite declares it
14
+ ## prints ([constant EXPECTED_OUT]).
15
15
 
16
16
  const LISTED: String = "--suites-in"
17
17
  const SKIPPING: String = "--skipping-in"
@@ -27,11 +27,15 @@ const REPORTS_OFF: Dictionary[String, String] = {
27
27
  ## does not compile named. project_test does not: its answer is about the scripts its run reached,
28
28
  ## and the compile names every broken script of the game, then once more as the one script it made.
29
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"
30
+ ## The file to write what every suite declares it prints into, as JSON: each key of
31
+ ## [constant EXPECTED] holding the declarations keyed by suite path and then case name.
32
+ const EXPECTED_OUT: String = "--expected-out"
33
+ ## The constants a suite declares what it prints on purpose in, case name to the exact texts: its
34
+ ## engine errors, and its engine warnings.
35
+ const EXPECTED: Dictionary[String, String] = {
36
+ "errors": "GDHARNESS_EXPECTED_ERRORS",
37
+ "warnings": "GDHARNESS_EXPECTED_WARNINGS",
38
+ }
35
39
  ## The tool name gdUnit4 reads its options after.
36
40
  const TOOL: String = "GdUnitCmdTool.gd"
37
41
  ## How long a windowless engine sleeps after each frame. The engine's own default is the
@@ -217,18 +221,18 @@ static func argument_after(args: PackedStringArray, flag: String) -> String:
217
221
  return args[at + 1]
218
222
 
219
223
 
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
224
+ ## What each of [param suites] declares in [param constant], by suite path and then case name, read
225
+ ## off the compiled suite's constants so a declaration is whatever the engine made of it. A directory
222
226
  ## listed is walked for its scripts, as gdUnit4 walks it for suites. Only a script whose source names
223
227
  ## the constant is loaded: gdUnit4 stops on a suite that does not load only when its own load is the
224
228
  ## 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:
229
+ static func expected_by_suite(suites: PackedStringArray, constant: String) -> Dictionary:
226
230
  var expected: Dictionary = {}
227
231
  for path: String in scripts_in(suites):
228
- if not FileAccess.file_exists(path) or not FileAccess.get_file_as_string(path).contains(EXPECTED):
232
+ if not FileAccess.file_exists(path) or not FileAccess.get_file_as_string(path).contains(constant):
229
233
  continue
230
234
  var suite: Script = load(path)
231
- var declared: Variant = suite.get_script_constant_map().get(EXPECTED)
235
+ var declared: Variant = suite.get_script_constant_map().get(constant)
232
236
  if declared is Dictionary:
233
237
  expected[path] = declared
234
238
  return expected
@@ -259,5 +263,8 @@ static func write_expected(path: String, suites: PackedStringArray) -> void:
259
263
  if file == null:
260
264
  push_error("Could not write the expected errors to %s: %s" % [path, FileAccess.get_open_error()])
261
265
  return
262
- var _stored: bool = file.store_string(JSON.stringify(expected_by_suite(suites)))
266
+ var expected: Dictionary = {}
267
+ for kind: String in EXPECTED:
268
+ expected[kind] = expected_by_suite(suites, EXPECTED[kind])
269
+ var _stored: bool = file.store_string(JSON.stringify(expected))
263
270
  file.close()