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.
- package/build/cli.js +4668 -3229
- package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
- package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
- package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
- package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
- package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
- package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
- package/build/godot/addons/gdharness_editor/reading.gd +112 -6
- package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
- package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
- package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
- package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
- package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
- package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
- package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
- package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
- package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
- package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
- package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
- package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
- package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
- package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
- package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
- package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
- package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
- package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
- package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
- package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
- package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
- package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
- package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
- package/build/godot/operations/animation_reads.gd +115 -4
- package/build/godot/operations/audio_buses.gd +93 -7
- package/build/godot/operations/classdb_queries.gd +119 -7
- package/build/godot/operations/declared_values.gd +272 -0
- package/build/godot/operations/dependencies.gd +154 -41
- package/build/godot/operations/editor_tools.gd +29 -13
- package/build/godot/operations/export_presets.gd +602 -4
- package/build/godot/operations/file_reads.gd +115 -18
- package/build/godot/operations/gdscript_analysis.gd +14 -0
- package/build/godot/operations/gdscript_authoring.gd +12 -2
- package/build/godot/operations/godot_operations.gd +18 -2
- package/build/godot/operations/import_pipeline.gd +20 -221
- package/build/godot/operations/input_actions.gd +127 -51
- package/build/godot/operations/project_config.gd +113 -68
- package/build/godot/operations/project_diagnostics.gd +4 -0
- package/build/godot/operations/project_validation.gd +266 -0
- package/build/godot/operations/property_paths.gd +89 -16
- package/build/godot/operations/property_values.gd +180 -121
- package/build/godot/operations/reading.gd +112 -6
- package/build/godot/operations/scene_connections.gd +222 -0
- package/build/godot/operations/serialisation.gd +19 -29
- package/build/godot/operations/setting_reads.gd +121 -0
- package/build/godot/operations/value_shapes.gd +128 -1
- package/build/godot/tests/listed.gd +20 -13
- package/build/keeper.js +1402 -707
- 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
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
31
|
-
## then case name.
|
|
32
|
-
const EXPECTED_OUT: String = "--expected-
|
|
33
|
-
## The
|
|
34
|
-
|
|
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
|
-
##
|
|
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(
|
|
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(
|
|
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
|
|
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()
|