gdharness 1.2.40 → 1.2.42

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 (31) hide show
  1. package/build/cli.js +103 -38
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +14 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +2 -1
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +134 -10
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +56 -15
  6. package/build/godot/addons/gdharness_editor/property_values.gd +1 -1
  7. package/build/godot/addons/gdharness_editor/reading.gd +40 -0
  8. package/build/godot/addons/gdharness_editor/scene_file.gd +215 -12
  9. package/build/godot/addons/gdharness_editor/serialisation.gd +5 -1
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +39 -5
  11. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -15
  12. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +346 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +438 -0
  15. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd.uid +1 -0
  16. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +102 -60
  17. package/build/godot/addons/gdharness_runtime/reading.gd +40 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +3 -1
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +2 -2
  20. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +11 -6
  21. package/build/godot/addons/gdharness_runtime/runtime_values.gd +3 -1
  22. package/build/godot/addons/gdharness_runtime/serialisation.gd +5 -1
  23. package/build/godot/operations/animation_reads.gd +14 -2
  24. package/build/godot/operations/editor_tools.gd +28 -1
  25. package/build/godot/operations/file_reads.gd +134 -10
  26. package/build/godot/operations/godot_operations.gd +2 -1
  27. package/build/godot/operations/property_values.gd +1 -1
  28. package/build/godot/operations/reading.gd +40 -0
  29. package/build/godot/operations/serialisation.gd +5 -1
  30. package/build/index.js +103 -38
  31. package/package.json +1 -1
@@ -15,9 +15,10 @@ const Read = preload("reading.gd")
15
15
  const Serialisation = preload("serialisation.gd")
16
16
 
17
17
 
18
- ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf on a node that
19
- ## instances another scene and every stored property when [param include_properties] is set, the
20
- ## file's value or the default where it sets none. [param depth] -1 reads every level.
18
+ ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
19
+ ## editableChildren on a node that instances another scene and every stored property when
20
+ ## [param include_properties] is set, the file's value or the default where it sets none.
21
+ ## [param depth] -1 reads every level.
21
22
  static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
23
  var laid: Dictionary = _laid(scene_path)
23
24
  if not laid.get("ok", false):
@@ -41,6 +42,31 @@ static func scene_node(scene_path: String, node_path: String) -> Dictionary:
41
42
  return {"ok": true, "name": found["name"], "type": found["type"], "properties": found["properties"]}
42
43
 
43
44
 
45
+ ## [param said], the refusal for [param node_path], which [param scene_path] does not have, with
46
+ ## where the path leaves the scene as [method Read.missing_below] says it.
47
+ static func missing_from(said: String, scene_path: String, node_path: String) -> String:
48
+ var laid: Dictionary = _laid(scene_path)
49
+ if not laid.get("ok", false):
50
+ return said
51
+ var nodes: Dictionary = laid["nodes"]
52
+ var parts: PackedStringArray = node_path.trim_prefix("./").split("/", false)
53
+ var at: String = "."
54
+ var reached: PackedStringArray = []
55
+ for part: String in parts:
56
+ var below: String = _joined(at, part)
57
+ if not nodes.has(below):
58
+ break
59
+ at = below
60
+ var _walked: bool = reached.append(part)
61
+ if reached.size() >= parts.size():
62
+ return said
63
+ var names: PackedStringArray = []
64
+ var children: Array[String] = nodes[at]["children"]
65
+ for child: String in children:
66
+ var _named: bool = names.append(str(nodes[child]["name"]))
67
+ return Read.missing_below(said, str(nodes["."]["name"]), reached, parts[reached.size()], names)
68
+
69
+
44
70
  ## Every node of [param scene_path] as {ok, nodes}, keyed by path, or a refusal.
45
71
  static func _laid(scene_path: String) -> Dictionary:
46
72
  if not FileAccess.file_exists(scene_path):
@@ -51,13 +77,11 @@ static func _laid(scene_path: String) -> Dictionary:
51
77
  if loaded == null:
52
78
  return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
53
79
  if not loaded is PackedScene:
80
+ var kind: String = loaded.get_class()
81
+ var sort: String = ("an " if kind.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + kind
54
82
  return {
55
83
  "ok": false,
56
- "error":
57
- (
58
- "%s is a %s, not a scene: resource_edit read reads its properties."
59
- % [scene_path, loaded.get_class()]
60
- )
84
+ "error": "%s is %s, not a scene: resource_edit read reads its properties." % [scene_path, sort]
61
85
  }
62
86
  var packed: PackedScene = loaded
63
87
  var nodes: Dictionary = {}
@@ -82,8 +106,18 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
82
106
  var refused: String = _lay(base, prefix, nodes, within)
83
107
  if not refused.is_empty():
84
108
  return refused
109
+ var ids: Array[int] = _unique_ids(state.get_path(), state.get_node_count())
85
110
  for index: int in state.get_node_count():
86
111
  var path: String = _joined(prefix, str(state.get_node_path(index)))
112
+ var recovered: bool = false
113
+ if not nodes.has(path) and index > 0 and _overrides(state, index) and ids[index] >= 0:
114
+ # The engine finds an entry whose node was renamed or moved in the scene it comes from by
115
+ # the node's unique_id, and keeps the node's new name.
116
+ var parent: String = _joined(prefix, str(state.get_node_path(index, true)))
117
+ var found: String = _by_id(nodes, _owner_of(nodes, parent, prefix), ids[index])
118
+ recovered = not found.is_empty()
119
+ if recovered:
120
+ path = found
87
121
  var fresh: bool = not nodes.has(path)
88
122
  if fresh:
89
123
  var instance: PackedScene = state.get_node_instance(index)
@@ -98,6 +132,7 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
98
132
  if not nodes.has(path):
99
133
  return "%s holds no nodes" % instance.resource_path
100
134
  nodes[path]["instanceOf"] = instance.resource_path
135
+ nodes[path]["editableChildren"] = false
101
136
  elif state.is_node_instance_placeholder(index):
102
137
  nodes[path] = _made("InstancePlaceholder")
103
138
  nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
@@ -106,9 +141,12 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
106
141
  continue
107
142
  else:
108
143
  nodes[path] = _made(str(state.get_node_type(index)))
144
+ if ids[index] >= 0:
145
+ nodes[path]["uniqueId"] = ids[index]
109
146
  # The name is the state's even for an entry laid already, since an inherited scene names its
110
147
  # root itself; a root instanced inside another scene is renamed by that scene once laid.
111
- nodes[path]["name"] = str(state.get_node_name(index))
148
+ if not recovered:
149
+ nodes[path]["name"] = str(state.get_node_name(index))
112
150
  if fresh:
113
151
  # The root of what is laid here is placed by whoever instanced it.
114
152
  if path != prefix:
@@ -123,6 +161,86 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
123
161
  held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
124
162
  index, property
125
163
  )
164
+ # Only the scene being read and the scenes it inherits share its root, the owner whose editable
165
+ # instances a write here sets; a file instanced below makes its own owner's editable.
166
+ if prefix == ".":
167
+ for editable: String in _editable_in(state.get_path()):
168
+ var at: String = _joined(prefix, editable)
169
+ var marked: Dictionary = nodes.get(at, {})
170
+ if marked.has("editableChildren"):
171
+ marked["editableChildren"] = true
172
+ return ""
173
+
174
+
175
+ ## The paths of the instances [param scene_path] gives editable children, from its root. SceneState
176
+ ## does not give them to a script, so they are read off the scene's bundle.
177
+ static func _editable_in(scene_path: String) -> PackedStringArray:
178
+ var found: PackedStringArray = []
179
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
180
+ if not loaded is PackedScene:
181
+ return found
182
+ var given: Variant = loaded.get("_bundled")
183
+ if not given is Dictionary:
184
+ return found
185
+ var bundled: Dictionary = given
186
+ var listed: Variant = bundled.get("editable_instances", [])
187
+ if listed is Array:
188
+ for one: Variant in listed:
189
+ var _kept: bool = found.append(str(one))
190
+ return found
191
+
192
+
193
+ ## The unique_id of each node entry in the text scene at [param scene_path], in the order its state
194
+ ## holds them, -1 where an entry has none. All -1 for a binary scene, and for a file whose entries do
195
+ ## not number [param count], since SceneState does not give the ids to a script.
196
+ static func _unique_ids(scene_path: String, count: int) -> Array[int]:
197
+ var ids: Array[int] = []
198
+ var _sized: int = ids.resize(count)
199
+ ids.fill(-1)
200
+ if not scene_path.ends_with(".tscn") or not FileAccess.file_exists(scene_path):
201
+ return ids
202
+ var read: Array[int] = []
203
+ var pattern: RegEx = RegEx.create_from_string(" unique_id=(\\d+)")
204
+ for line: String in FileAccess.get_file_as_string(scene_path).split("\n"):
205
+ if line.begins_with("[node "):
206
+ var found: RegExMatch = pattern.search(line)
207
+ read.append(int(found.get_string(1)) if found != null else -1)
208
+ return read if read.size() == count else ids
209
+
210
+
211
+ ## Whether entry [param index] of [param state] only sets values on a node another file makes.
212
+ static func _overrides(state: SceneState, index: int) -> bool:
213
+ return (
214
+ state.get_node_instance(index) == null
215
+ and not state.is_node_instance_placeholder(index)
216
+ and str(state.get_node_type(index)).is_empty()
217
+ )
218
+
219
+
220
+ ## The node whose file owns what sits under [param parent]: the nearest instance at or above it, or
221
+ ## [param prefix], the root of what is being laid, as the engine searches.
222
+ static func _owner_of(nodes: Dictionary, parent: String, prefix: String) -> String:
223
+ var at: String = parent
224
+ while at != prefix and at != "." and nodes.has(at):
225
+ var entry: Dictionary = nodes[at]
226
+ if entry.has("instanceOf"):
227
+ break
228
+ at = "." if not at.contains("/") else at.get_base_dir()
229
+ return at if nodes.has(at) else prefix
230
+
231
+
232
+ ## The path of the node [param owner] owns whose unique_id is [param id], or "": one under an instance
233
+ ## nested below [param owner] belongs to that instance's file, whose ids are numbered apart.
234
+ static func _by_id(nodes: Dictionary, owner: String, id: int) -> String:
235
+ for path: String in nodes:
236
+ if path == owner or not (owner == "." or path.begins_with(owner + "/")):
237
+ continue
238
+ var entry: Dictionary = nodes[path]
239
+ if Read.as_int(entry.get("uniqueId", -1), -1) != id:
240
+ continue
241
+ var above: String = "." if not path.contains("/") else path.get_base_dir()
242
+ if _owner_of(nodes, above, owner) == owner:
243
+ return path
126
244
  return ""
127
245
 
128
246
 
@@ -162,12 +280,18 @@ static func _entry(
162
280
  # The root's file is the scene being read, so only another node's names a second scene.
163
281
  if path != "." and node.has("instanceOf"):
164
282
  data["instanceOf"] = node["instanceOf"]
283
+ if node.has("editableChildren"):
284
+ data["editableChildren"] = node["editableChildren"]
165
285
  if include_properties:
166
286
  var given: Dictionary = node["properties"]
167
287
  data["properties"] = _stored(str(node["type"]), given)
288
+ var below: Array[String] = node["children"]
289
+ # Counted rather than left as an empty list, which reads as a node with no children.
168
290
  if depth >= 0 and current_depth >= depth:
291
+ if not below.is_empty():
292
+ data["childrenNotListed"] = below.size()
169
293
  return data
170
- for child: String in node["children"]:
294
+ for child: String in below:
171
295
  children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
172
296
  return data
173
297
 
@@ -147,12 +147,8 @@ static func reached(object: Object, property: String) -> Dictionary:
147
147
  ## Whether [param object] holds, at any depth, an embedded resource with a script that is not a tool.
148
148
  static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
149
149
  for value: Variant in stored(object).values():
150
- var items: Array = value if value is Array else [value]
151
- for item: Variant in items:
152
- if not item is Resource or seen.has(item):
153
- continue
154
- var resource: Resource = item
155
- if not resource.is_built_in():
150
+ for resource: Resource in resources_in(value):
151
+ if seen.has(resource) or not resource.is_built_in():
156
152
  continue
157
153
  seen[resource] = true
158
154
  var script: Script = resource.get_script()
@@ -163,6 +159,25 @@ static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
163
159
  return false
164
160
 
165
161
 
162
+ ## The resources [param value] is or holds through arrays and dictionaries at any depth, keys
163
+ ## included: a file embeds a resource kept as a map's value, the way an AnimationLibrary keeps its
164
+ ## animations, as it embeds one held directly.
165
+ static func resources_in(value: Variant) -> Array[Resource]:
166
+ var found: Array[Resource] = []
167
+ if value is Resource:
168
+ found.append(value)
169
+ elif value is Array:
170
+ var items: Array = value
171
+ for item: Variant in items:
172
+ found.append_array(resources_in(item))
173
+ elif value is Dictionary:
174
+ var entries_held: Dictionary = value
175
+ for key: Variant in entries_held:
176
+ found.append_array(resources_in(key))
177
+ found.append_array(resources_in(entries_held[key]))
178
+ return found
179
+
180
+
166
181
  ## Puts the file at [param file_path] back as [param previous], or removes it when there was none,
167
182
  ## answering "" or a sentence saying that failed too.
168
183
  static func put_back(file_path: String, previous: Variant) -> String:
@@ -233,7 +248,8 @@ static func entries(lines: PackedStringArray) -> Dictionary:
233
248
 
234
249
 
235
250
  ## Each property the main resource of [param after] sets differently from [param before], leaving
236
- ## out [param asked], as [name, whether before sets it, the text before gives it].
251
+ ## out [param asked], as [name, whether before sets it, the text before gives it, the text after
252
+ ## gives it or null].
237
253
  static func main_changes(before: String, after: String, asked: Dictionary) -> Array[Array]:
238
254
  var was: Dictionary = _main_entries(before)
239
255
  var now: Dictionary = _main_entries(after)
@@ -244,10 +260,25 @@ static func main_changes(before: String, after: String, asked: Dictionary) -> Ar
244
260
  var name: String = str(key)
245
261
  if asked.has(name) or was.get(name) == now.get(name):
246
262
  continue
247
- changed.append([name, was.has(name), str(was.get(name, ""))])
263
+ changed.append([name, was.has(name), str(was.get(name, "")), now.get(name)])
248
264
  return changed
249
265
 
250
266
 
267
+ ## Whether [param was] and [param now], the texts two files give a property or null where one sets
268
+ ## nothing, read as the same value, nothing standing for [param default]. A file written by hand
269
+ ## spells a value its own way, 1 for 1.0, and may set a default, which the saver leaves out.
270
+ static func reads_the_same(was: Variant, now: Variant, default: Variant) -> bool:
271
+ var values: Array = []
272
+ for text: Variant in [was, now]:
273
+ if text == null:
274
+ values.append(default)
275
+ elif "Resource(" in str(text):
276
+ return false
277
+ else:
278
+ values.append(str_to_var(str(text)))
279
+ return PropertyValues.same(values[0], values[1])
280
+
281
+
251
282
  static func _main_entries(text: String) -> Dictionary:
252
283
  var main: Dictionary = sections(text).get("resource", {})
253
284
  return entries_of(main)
@@ -283,7 +314,8 @@ static func scripted_sections(text: String) -> Dictionary:
283
314
 
284
315
 
285
316
  ## Each property an embedded resource of [param after] sets differently from [param before], as
286
- ## [its id, name, whether before sets it, the text before gives it]. Only those with a script that
317
+ ## [its id, name, whether before sets it, the text before gives it, the text after gives it or
318
+ ## null]. Only those with a script that
287
319
  ## is not a tool are compared, since only a script runs differently here, and those in
288
320
  ## [param reached_ids], which the tool changed, are left out.
289
321
  static func embedded_changes(before: String, after: String, reached_ids: Dictionary) -> Array[Array]:
@@ -306,7 +338,7 @@ static func embedded_changes(before: String, after: String, reached_ids: Diction
306
338
  for entry: Variant in all:
307
339
  var name: String = str(entry)
308
340
  if was.get(name) != now.get(name):
309
- changed.append([id, name, was.has(name), str(was.get(name, ""))])
341
+ changed.append([id, name, was.has(name), str(was.get(name, "")), now.get(name)])
310
342
  return changed
311
343
 
312
344
 
@@ -330,11 +362,15 @@ static func settle_saved(
330
362
  var embedded: Array[Array] = []
331
363
  for found: Array in embedded_changes(before, after, reached_ids):
332
364
  var holder: Resource = by_id.get(found[0])
365
+ if holder != null and _unchanged(holder, found.slice(1)):
366
+ continue
333
367
  if found[2] or holder == null or not at_default(holder, str(found[1])):
334
368
  embedded.append(found)
335
369
  var top: Array[Array] = []
336
370
  if main != null:
337
371
  for found: Array in main_changes(before, after, asked):
372
+ if _unchanged(main, found):
373
+ continue
338
374
  if found[1] or not at_default(main, str(found[0])):
339
375
  top.append(found)
340
376
  if embedded.is_empty() and top.is_empty():
@@ -367,14 +403,19 @@ static func settle_saved(
367
403
  return ""
368
404
 
369
405
 
406
+ ## Whether the saved text gives a property of [param object] what the file did, spelt another way.
407
+ ## [param change] is [name, whether the file set it, the text it gave, the text after or null].
408
+ static func _unchanged(object: Object, change: Array) -> bool:
409
+ var name: String = change[0]
410
+ var was_set: bool = change[1]
411
+ var was: Variant = change[2] if was_set else null
412
+ return reads_the_same(was, change[3], default_of(object, name))
413
+
414
+
370
415
  ## Every embedded resource [param values] reach, at any depth, by its id in the file.
371
416
  static func embedded_in(values: Array, found: Dictionary) -> Dictionary:
372
417
  for value: Variant in values:
373
- var items: Array = value if value is Array else [value]
374
- for item: Variant in items:
375
- if not item is Resource:
376
- continue
377
- var resource: Resource = item
418
+ for resource: Resource in resources_in(value):
378
419
  var id: String = resource.get_scene_unique_id()
379
420
  if not resource.is_built_in() or id.is_empty() or found.has(id):
380
421
  continue
@@ -590,7 +590,7 @@ static func _listed(type: int) -> bool:
590
590
 
591
591
  ## [param value] as a caller would write it, for a refusal to quote.
592
592
  func shown(value: Variant) -> String:
593
- return JSON.stringify(_values.serialize_value(value))
593
+ return JSON.stringify(_values.serialize_value(value), "", false)
594
594
 
595
595
 
596
596
  ## A dictionary carrying its own type name, as the serialiser writes it.
@@ -22,6 +22,9 @@ extends RefCounted
22
22
  # number was expected. Absent keys never reach here: the callers pass their own default to
23
23
  # `Dictionary.get`, so what arrives is that default.
24
24
 
25
+ ## How many of a node's children a refusal for a path missing below it names.
26
+ const NAMED_CHILDREN: int = 12
27
+
25
28
 
26
29
  static func as_int(value: Variant, fallback: int = 0) -> int:
27
30
  if value is int:
@@ -99,3 +102,40 @@ static func json_or_null(text: String) -> Variant:
99
102
  if json.parse(text) != OK:
100
103
  return null
101
104
  return json.data
105
+
106
+
107
+ ## [param said], the refusal for [param node_path] found nowhere in a scene whose root is called
108
+ ## [param root_name], with how paths are read when the path starts with that name and the root has
109
+ ## no child called that. Paths go from the root, so its own name there names a child of it, and a
110
+ ## caller who read the root as Main in a tree wrote Main/Body and was told only that nothing is there.
111
+ static func from_the_root(said: String, node_path: String, root_name: String) -> String:
112
+ var first: String = node_path.get_slice("/", 0)
113
+ if root_name.is_empty() or first != root_name:
114
+ return said
115
+ var rest: String = node_path.substr(first.length() + 1)
116
+ var how: String = 'Paths go from the root, %s, whose own path is "."' % root_name
117
+ if not rest.is_empty():
118
+ how += ', so this one is "%s"' % rest
119
+ return "%s%s %s." % [said, "" if said.ends_with(".") else ".", how]
120
+
121
+
122
+ ## [param said], the refusal for a path a scene does not have, with where the path leaves it: the
123
+ ## last node of the path it has, [param reached] from the root, called [param root_name], when that
124
+ ## is empty; the name below it that is missing; and [param children], the names that node has, as
125
+ ## after a node was moved or renamed. Names up to [constant NAMED_CHILDREN] of them.
126
+ static func missing_below(
127
+ said: String, root_name: String, reached: PackedStringArray, missing: String, children: PackedStringArray
128
+ ) -> String:
129
+ var shown: String = ", ".join(children.slice(0, NAMED_CHILDREN))
130
+ if children.size() > NAMED_CHILDREN:
131
+ shown += " and %d more" % (children.size() - NAMED_CHILDREN)
132
+ return (
133
+ "%s%s %s has no %s; %s."
134
+ % [
135
+ said,
136
+ "" if said.ends_with(".") else ".",
137
+ "The root, %s," % root_name if reached.is_empty() else "/".join(reached),
138
+ missing,
139
+ "its children are " + shown if not children.is_empty() else "it has no children",
140
+ ]
141
+ )