# ############################################################################## #(G)odot (U)nit (T)est class # # ############################################################################## # The MIT License (MIT) # ===================== # # Copyright (c) 2020 Tom "Butch" Wesley # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # ############################################################################## # Description # ----------- # ############################################################################## # ------------------------------------------------------------------------------ # Utility class to hold the local and built in methods separately. Add all local # methods FIRST, then add built ins. # ------------------------------------------------------------------------------ class ScriptMethods: # List of methods that should not be overloaded when they are not defined # in the class being doubled. These either break things if they are # overloaded or do not have a "super" equivalent so we can't just pass # through. var _blacklist = [ "has_method", "get_script", "get", "_notification", "get_path", "_enter_tree", "_exit_tree", "_process", "_draw", "_physics_process", "_input", "_unhandled_input", "_unhandled_key_input", "_set", "_get", # probably "emit_signal", # can't handle extra parameters to be sent with signal. "draw_mesh", # issue with one parameter, value is `Null((..), (..), (..))`` "_to_string", # nonexistant function ._to_string "_get_minimum_size", # Nonexistent function _get_minimum_size ] # These methods should not be included in the double. var _skip = [ # There is an init in the template. There is also no real reason # to include this method since it will always be called, it has no # return value, and you cannot prevent super from being called. "_init" ] var built_ins = [] var local_methods = [] var _method_names = [] func is_blacklisted(method_meta): return _blacklist.find(method_meta.name) != -1 func _add_name_if_does_not_have(method_name): if _skip.has(method_name): return false var should_add = _method_names.find(method_name) == -1 if should_add: _method_names.append(method_name) return should_add func add_built_in_method(method_meta): var did_add = _add_name_if_does_not_have(method_meta.name) if did_add and !is_blacklisted(method_meta): built_ins.append(method_meta) func add_local_method(method_meta): var did_add = _add_name_if_does_not_have(method_meta.name) if did_add: local_methods.append(method_meta) func to_s(): var text = "Locals\n" for i in range(local_methods.size()): text += str(" ", local_methods[i].name, "\n") text += "Built-Ins\n" for i in range(built_ins.size()): text += str(" ", built_ins[i].name, "\n") return text # ------------------------------------------------------------------------------ # Helper class to deal with objects and inner classes. # ------------------------------------------------------------------------------ class ObjectInfo: var _path = null var _subpaths = [] var _utils = load("res://addons/gut/utils.gd").get_instance() var _lgr = _utils.get_logger() var _method_strategy = null var make_partial_double = false var scene_path = null var _native_class = null var _native_class_name = null var _singleton_instance = null var _singleton_name = null func _init(path, subpath = null): _path = path if subpath != null: _subpaths = Array(subpath.split("/")) # Returns an instance of the class/inner class func instantiate(): var to_return = null if _singleton_instance != null: to_return = _singleton_instance elif is_native(): to_return = _native_class.new() else: to_return = get_loaded_class().new() return to_return # Can't call it get_class because that is reserved so it gets this ugly name. # Loads up the class and then any inner classes to give back a reference to # the desired Inner class (if there is any) func get_loaded_class(): var LoadedClass = load(_path) for i in range(_subpaths.size()): LoadedClass = LoadedClass.get(_subpaths[i]) return LoadedClass func to_s(): return str(_path, "[", get_subpath(), "]") func get_path(): return _path func get_subpath(): return "/".join(PackedStringArray(_subpaths)) func has_subpath(): return _subpaths.size() != 0 func get_method_strategy(): return _method_strategy func set_method_strategy(method_strategy): _method_strategy = method_strategy func is_native(): return _native_class != null func set_native_class(native_class): _native_class = native_class var inst = native_class.new() _native_class_name = inst.get_class() _path = _native_class_name if !inst is RefCounted: inst.free() func get_native_class_name(): return _native_class_name func get_singleton_instance(): return _singleton_instance func get_singleton_name(): return _singleton_name func set_singleton_name(singleton_name): _singleton_name = singleton_name _singleton_instance = _utils.get_singleton_by_name(_singleton_name) func is_singleton(): return _singleton_instance != null func get_extends_text(): var extend = null if is_singleton(): extend = str("# Double of singleton ", _singleton_name, ", base class is RefCounted") elif is_native(): var native = get_native_class_name() if native.begins_with("_"): native = native.substr(1) extend = str("extends ", native) else: extend = str("extends '", get_path(), "'") if has_subpath(): extend += str(".", get_subpath().replace("/", ".")) return extend func get_constants_text(): if !is_singleton(): return "" # do not include constants defined in the super class which for # singletons stubs is RefCounted. var exclude_constants = Array(ClassDB.class_get_integer_constant_list("RefCounted")) var text = str("# -----\n# ", _singleton_name, " Constants\n# -----\n") var constants = ClassDB.class_get_integer_constant_list(_singleton_name) for c in constants: if !exclude_constants.has(c): var value = ClassDB.class_get_integer_constant(_singleton_name, c) text += str("const ", c, " = ", value, "\n") return text func get_properties_text(): if !is_singleton(): return "" var text = str("# -----\n# ", _singleton_name, " Properties\n# -----\n") var props = ClassDB.class_get_property_list(_singleton_name) for prop in props: var accessors = {"setter": null, "getter": null} var prop_text = str("var ", prop["name"]) var getter_name = "get_" + prop["name"] if ClassDB.class_has_method(_singleton_name, getter_name): accessors.getter = getter_name else: getter_name = "is_" + prop["name"] if ClassDB.class_has_method(_singleton_name, getter_name): accessors.getter = getter_name var setter_name = "set_" + prop["name"] if ClassDB.class_has_method(_singleton_name, setter_name): accessors.setter = setter_name var setget_text = "" if accessors.setter != null and accessors.getter != null: setget_text = str("setget ", accessors.setter, ", ", accessors.getter) else: # never seen this message show up, but it should show up if we # get misbehaving singleton. _lgr.error( str( "Could not find setget methods for property: ", _singleton_name, ".", prop["name"] ) ) text += str(prop_text, " ", setget_text, "\n") return text # ------------------------------------------------------------------------------ # Allows for interacting with a file but only creating a string. This was done # to ease the transition from files being created for doubles to loading # doubles from a string. This allows the files to be created for debugging # purposes since reading a file is easier than reading a dumped out string. # ------------------------------------------------------------------------------ class FileOrString: extends File var _do_file = false var _contents = "" var _path = null func open(path, mode): _path = path if _do_file: return super.open(path, mode) else: return OK func close(): if _do_file: return super.close() func store_string(s): if _do_file: super.store_string(s) _contents += s func get_contents(): return _contents func get_path(): return _path func load_it(): if _contents != "": var script = GDScript.new() script.set_source_code(get_contents()) script.reload() return script else: return load(_path) # ------------------------------------------------------------------------------ # A stroke of genius if I do say so. This allows for doubling a scene without # having to write any files. By overloading the "instance" method we can # make whatever we want. # ------------------------------------------------------------------------------ class PackedSceneDouble: extends PackedScene var _script = null var _scene = null func set_script_obj(obj): _script = obj func instance(edit_state = 0): var inst = _scene.instantiate(edit_state) if _script != null: inst.set_script(_script) return inst func load_scene(path): _scene = load(path) # ------------------------------------------------------------------------------ # START Doubler # ------------------------------------------------------------------------------ var _utils = load("res://addons/gut/utils.gd").get_instance() var _ignored_methods = _utils.OneToMany.new() var _stubber = _utils.Stubber.new() var _lgr = _utils.get_logger() var _method_maker = _utils.MethodMaker.new() var _output_dir = "user://gut_temp_directory" var _double_count = 0 # used in making files names unique var _spy = null var _gut = null var _strategy = null var _base_script_text = _utils.get_file_as_text( "res://addons/gut/double_templates/script_template.txt" ) var _make_files = false # used by tests for debugging purposes. var _print_source = false func _init(strategy = _utils.DOUBLE_STRATEGY.PARTIAL): set_logger(_utils.get_logger()) _strategy = strategy # ############### # Private # ############### func _get_indented_line(indents, text): var to_return = "" for _i in range(indents): to_return += "\t" return str(to_return, text, "\n") func _stub_to_call_super(obj_info, method_name): if _utils.non_super_methods.has(method_name): return var path = obj_info.get_path() if obj_info.is_singleton(): path = obj_info.get_singleton_name() elif obj_info.scene_path != null: path = obj_info.scene_path var params = _utils.StubParams.new(path, method_name, obj_info.get_subpath()) params.to_call_super() _stubber.add_stub(params) func _get_base_script_text(obj_info, override_path): var path = obj_info.get_path() if override_path != null: path = override_path var stubber_id = -1 if _stubber != null: stubber_id = _stubber.get_instance_id() var spy_id = -1 if _spy != null: spy_id = _spy.get_instance_id() var gut_id = -1 if _gut != null: gut_id = _gut.get_instance_id() var values = { # Top sections "extends": obj_info.get_extends_text(), "constants": obj_info.get_constants_text(), "properties": obj_info.get_properties_text(), # metadata values "path": path, "subpath": obj_info.get_subpath(), "stubber_id": stubber_id, "spy_id": spy_id, "gut_id": gut_id, "singleton_name": _utils.nvl(obj_info.get_singleton_name(), ""), "is_partial": str(obj_info.make_partial_double).to_lower() } return _base_script_text.format(values) func _write_file(obj_info, dest_path, override_path = null): var base_script = _get_base_script_text(obj_info, override_path) var script_methods = _get_methods(obj_info) var super_name = "" var path = "" if obj_info.is_singleton(): super_name = obj_info.get_singleton_name() else: path = obj_info.get_path() var f = FileOrString.new() f._do_file = _make_files var f_result = f.open(dest_path, f.WRITE) if f_result != OK: _lgr.error(str("Error creating file ", dest_path)) _lgr.error(str("Could not create double for :", obj_info.to_s())) return f.store_string(base_script) for i in range(script_methods.local_methods.size()): f.store_string(_get_func_text(script_methods.local_methods[i], path, super_name)) for i in range(script_methods.built_ins.size()): _stub_to_call_super(obj_info, script_methods.built_ins[i].name) f.store_string(_get_func_text(script_methods.built_ins[i], path, super_name)) f.close() if _print_source: print(f.get_contents()) return f func _double_scene_and_script(scene_info): var to_return = PackedSceneDouble.new() to_return.load_scene(scene_info.get_path()) var inst = load(scene_info.get_path()).instantiate() var script_path = null if inst.get_script(): script_path = inst.get_script().get_path() inst.free() if script_path: var oi = ObjectInfo.new(script_path) oi.set_method_strategy(scene_info.get_method_strategy()) oi.make_partial_double = scene_info.make_partial_double oi.scene_path = scene_info.get_path() to_return.set_script_obj(_double(oi, scene_info.get_path()).load_it()) return to_return func _get_methods(object_info): var obj = object_info.instantiate() # any method in the script or super script var script_methods = ScriptMethods.new() var methods = obj.get_method_list() if !object_info.is_singleton() and !(obj is RefCounted): obj.free() # first pass is for local methods only for i in range(methods.size()): if object_info.is_singleton(): #print(methods[i].name, " :: ", methods[i].flags, " :: ", methods[i].id) #print(" ", methods[i]) # It appears that the ID for methods upstream from a singleton are # below 200. Initially it was thought that singleton specific methods # were above 1000. This was true for Input but not for OS. I've # changed the condition to be > 200 instead of > 1000. It will take # some investigation to figure out if this is right, but it works # for now. Someone either find an issue and open a bug, or this will # just exist like this. Sorry future me (or someone else). if methods[i].id > 200 and methods[i].flags in [1, 9]: script_methods.add_local_method(methods[i]) # 65 is a magic number for methods in script, though documentation # says 64. This picks up local overloads of base class methods too. # See MethodFlags in @GlobalScope elif ( methods[i].flags == 65 and !_ignored_methods.has(object_info.get_path(), methods[i]["name"]) ): script_methods.add_local_method(methods[i]) if object_info.get_method_strategy() == _utils.DOUBLE_STRATEGY.FULL: # second pass is for anything not local for j in range(methods.size()): # 65 is a magic number for methods in script, though documentation # says 64. This picks up local overloads of base class methods too. if ( methods[j].flags != 65 and !_ignored_methods.has(object_info.get_path(), methods[j]["name"]) ): script_methods.add_built_in_method(methods[j]) return script_methods func _get_inst_id_ref_str(inst): var ref_str = "null" if inst: ref_str = str("instance_from_id(", inst.get_instance_id(), ")") return ref_str func _get_func_text(method_hash, path, super = ""): var override_count = null if _stubber != null: override_count = _stubber.get_parameter_count(path, method_hash.name) var text = _method_maker.get_function_text(method_hash, path, override_count, super) + "\n" return text # returns the path to write the double file to func _get_temp_path(object_info): var file_name = null var extension = null if object_info.is_singleton(): file_name = str(object_info.get_singleton_instance()) extension = "gd" elif object_info.is_native(): file_name = object_info.get_native_class_name() extension = "gd" else: file_name = object_info.get_path().get_file().get_basename() extension = object_info.get_path().get_extension() if object_info.has_subpath(): file_name += "__" + object_info.get_subpath().replace("/", "__") file_name += str("__dbl", _double_count, "__.", extension) var to_return = _output_dir.plus_file(file_name) return to_return func _double(obj_info, override_path = null): var temp_path = _get_temp_path(obj_info) var result = _write_file(obj_info, temp_path, override_path) _double_count += 1 return result func _double_script(path, make_partial, strategy): var oi = ObjectInfo.new(path) oi.make_partial_double = make_partial oi.set_method_strategy(strategy) return _double(oi).load_it() func _double_inner(path, subpath, make_partial, strategy): var oi = ObjectInfo.new(path, subpath) oi.set_method_strategy(strategy) oi.make_partial_double = make_partial return _double(oi).load_it() func _double_scene(path, make_partial, strategy): var oi = ObjectInfo.new(path) oi.set_method_strategy(strategy) oi.make_partial_double = make_partial return _double_scene_and_script(oi) func _double_gdnative(native_class, make_partial, strategy): var oi = ObjectInfo.new(null) oi.set_native_class(native_class) oi.set_method_strategy(strategy) oi.make_partial_double = make_partial return _double(oi).load_it() func _double_singleton(singleton_name, make_partial, strategy): var oi = ObjectInfo.new(null) oi.set_singleton_name(singleton_name) oi.set_method_strategy(_utils.DOUBLE_STRATEGY.PARTIAL) oi.make_partial_double = make_partial return _double(oi).load_it() # ############### # Public # ############### func get_output_dir(): return _output_dir func set_output_dir(output_dir): if output_dir != null: _output_dir = output_dir if _make_files: var d = Directory.new() d.make_dir_recursive(output_dir) func get_spy(): return _spy func set_spy(spy): _spy = spy func get_stubber(): return _stubber func set_stubber(stubber): _stubber = stubber func get_logger(): return _lgr func set_logger(logger): _lgr = logger _method_maker.set_logger(logger) func get_strategy(): return _strategy func set_strategy(strategy): _strategy = strategy func get_gut(): return _gut func set_gut(gut): _gut = gut func partial_double_scene(path, strategy = _strategy): return _double_scene(path, true, strategy) # double a scene func double_scene(path, strategy = _strategy): return _double_scene(path, false, strategy) # double a script/object func double(path, strategy = _strategy): return _double_script(path, false, strategy) func partial_double(path, strategy = _strategy): return _double_script(path, true, strategy) func partial_double_inner(path, subpath, strategy = _strategy): return _double_inner(path, subpath, true, strategy) # double an inner class in a script func double_inner(path, subpath, strategy = _strategy): return _double_inner(path, subpath, false, strategy) # must always use FULL strategy since this is a native class and you won't get # any methods if you don't use FULL func double_gdnative(native_class): return _double_gdnative(native_class, false, _utils.DOUBLE_STRATEGY.FULL) # must always use FULL strategy since this is a native class and you won't get # any methods if you don't use FULL func partial_double_gdnative(native_class): return _double_gdnative(native_class, true, _utils.DOUBLE_STRATEGY.FULL) func double_singleton(name): return _double_singleton(name, false, _utils.DOUBLE_STRATEGY.PARTIAL) func partial_double_singleton(name): return _double_singleton(name, true, _utils.DOUBLE_STRATEGY.PARTIAL) func clear_output_directory(): if !_make_files: return false var did = false if _output_dir.find("user://") == 0: var d = Directory.new() var result = d.open(_output_dir) # BIG GOTCHA HERE. If it cannot open the dir w/ erro 31, then the # directory becomes res:// and things go on normally and gut clears out # out res:// which is SUPER BAD. if result == OK: d.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547 var f = d.get_next() while f != "": d.remove_at(f) f = d.get_next() did = true return did func delete_output_directory(): var did = clear_output_directory() if did: var d = Directory.new() d.remove_at(_output_dir) func add_ignored_method(path, method_name): _ignored_methods.add(path, method_name) func get_ignored_methods(): return _ignored_methods func get_make_files(): return _make_files func set_make_files(make_files): _make_files = make_files set_output_dir(_output_dir) func get_method_maker(): return _method_maker