diff options
| author | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
|---|---|---|
| committer | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
| commit | a89e744ed0e8c759caf041a580112365bbfe4303 (patch) | |
| tree | dd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src/mocking | |
| parent | 6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff) | |
Diffstat (limited to 'addons/gdUnit4/src/mocking')
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMock.gd | 45 | ||||
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMock.gd.uid | 1 | ||||
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd | 186 | ||||
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid | 1 | ||||
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMockImpl.gd | 143 | ||||
| -rw-r--r-- | addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid | 1 |
6 files changed, 377 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd b/addons/gdUnit4/src/mocking/GdUnitMock.gd new file mode 100644 index 0000000..c520d92 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd @@ -0,0 +1,45 @@ +class_name GdUnitMock +extends RefCounted + +## do call the real implementation +const CALL_REAL_FUNC = "CALL_REAL_FUNC" +## do return a default value for primitive types or null +const RETURN_DEFAULTS = "RETURN_DEFAULTS" +## do return a default value for primitive types and a fully mocked value for Object types +## builds full deep mocked object +const RETURN_DEEP_STUB = "RETURN_DEEP_STUB" + +var _value: Variant + + +func _init(value: Variant) -> void: + _value = value + + +## Selects the mock to work on, used in combination with [method GdUnitTestSuite.do_return][br] +## Example: +## [codeblock] +## do_return(false).on(myMock).is_selected() +## [/codeblock] +func on(obj: Variant) -> Variant: + if not GdUnitMock._is_mock_or_spy(obj, "__do_return"): + return obj + @warning_ignore("unsafe_method_access") + return obj.__do_return(_value) + + +## [color=yellow]`checked` is obsolete, use `on` instead [/color] +func checked(obj :Object) -> Object: + push_warning("Using a deprecated function 'checked' use `on` instead") + return on(obj) + + +static func _is_mock_or_spy(obj: Variant, func_sig: String) -> bool: + if obj is Object and not as_object(obj).has_method(func_sig): + push_error("Error: You try to use a non mock or spy!") + return false + return true + + +static func as_object(value: Variant) -> Object: + return value diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid new file mode 100644 index 0000000..8470002 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid @@ -0,0 +1 @@ +uid://cknx4b72mqfq6 diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd new file mode 100644 index 0000000..537e923 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd @@ -0,0 +1,186 @@ +class_name GdUnitMockBuilder +extends GdUnitClassDoubler + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const MOCK_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/mocking/GdUnitMockImpl.gd") + + +static func is_push_errors() -> bool: + return GdUnitSettings.is_report_push_errors() + + +static func build(clazz :Variant, mock_mode :String, debug_write := false) -> Variant: + var push_errors := is_push_errors() + if not is_mockable(clazz, push_errors): + return null + # mocking a scene? + if GdObjects.is_scene(clazz): + var packed_scene: PackedScene = clazz + return mock_on_scene(packed_scene, debug_write) + elif typeof(clazz) == TYPE_STRING and str(clazz).ends_with(".tscn"): + var packed_scene: PackedScene = load(str(clazz)) + return mock_on_scene(packed_scene, debug_write) + # mocking a script + var instance := create_instance(clazz) + if instance == null: + push_error("Can't create instance of class %s" % clazz) + var mock := mock_on_script(instance, clazz, [ "get_script"], debug_write) + if not instance is RefCounted: + instance.free() + if mock == null: + return null + var mock_instance: Object = mock.new() + @warning_ignore("unsafe_method_access") + mock_instance.__init(mock, mock_mode) + return register_auto_free(mock_instance) + + +static func create_instance(clazz: Variant) -> Object: + match typeof(clazz): + TYPE_OBJECT: + var obj: Object = clazz + if clazz is GDScript: + var script: GDScript = clazz + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif obj.is_class("GDScriptNativeClass"): + @warning_ignore("unsafe_method_access") + return obj.new() + TYPE_STRING: + var clazz_name: String = clazz + if clazz_name.ends_with(".gd"): + var script: GDScript = load(clazz_name) + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif ClassDB.can_instantiate(clazz_name): + return ClassDB.instantiate(clazz_name) + + push_error("Can't create a mock validation instance from class: `%s`" % clazz) + return null + + +static func mock_on_scene(scene: PackedScene, debug_write: bool) -> Variant: + var push_errors := is_push_errors() + if not scene.can_instantiate(): + if push_errors: + push_error("Can't instanciate scene '%s'" % scene.resource_path) + return null + var scene_instance := scene.instantiate() + # we can only mock checked a scene with attached script + var scene_script: Script = scene_instance.get_script() + if scene_script == null: + if push_errors: + push_error("Can't create a mockable instance for a scene without script '%s'" % scene.resource_path) + @warning_ignore("return_value_discarded") + GdUnitTools.free_instance(scene_instance) + return null + + var script_path := scene_script.get_path() + var mock := mock_on_script(scene_instance, script_path, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write) + if mock == null: + return null + scene_instance.set_script(mock) + @warning_ignore("unsafe_method_access") + scene_instance.__init(mock, GdUnitMock.CALL_REAL_FUNC) + return register_auto_free(scene_instance) + + +static func get_class_info(clazz :Variant) -> Dictionary: + var clazz_name :String = GdObjects.extract_class_name(clazz).value() + var clazz_path := GdObjects.extract_class_path(clazz) + return { + "class_name" : clazz_name, + "class_path" : clazz_path + } + + +static func mock_on_script(instance :Object, clazz :Variant, function_excludes :PackedStringArray, debug_write :bool) -> GDScript: + var function_doubler := GdUnitMockFunctionDoubler.new() + var class_info := get_class_info(clazz) + var clazz_name :String = class_info.get("class_name") + var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name]) + var mock_template := MOCK_TEMPLATE.source_code.format({ + "instance_id" : abs(instance.get_instance_id()), + "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0] + }) + var lines := load_template(mock_template, class_info) + lines += double_functions(instance, clazz_name, clazz_path, function_doubler, function_excludes) + # We disable warning/errors for inferred_declaration + if Engine.get_version_info().hex >= 0x40400: + lines.insert(0, '@warning_ignore_start("inferred_declaration")') + lines.append('@warning_ignore_restore("inferred_declaration")') + + var mock := GDScript.new() + mock.source_code = "\n".join(lines) + mock.resource_name = "Mock%s_%d.gd" % [clazz_name, Time.get_ticks_msec()] + mock.resource_path = "%s/%s" % [GdUnitFileAccess.create_temp_dir("mock"), mock.resource_name] + + if debug_write: + @warning_ignore("return_value_discarded") + DirAccess.remove_absolute(mock.resource_path) + @warning_ignore("return_value_discarded") + ResourceSaver.save(mock, mock.resource_path) + var error := mock.reload(true) + if error != OK: + push_error("Critical!!!, MockBuilder error, please contact the developer.") + return null + return mock + + +static func is_mockable(clazz :Variant, push_errors :bool=false) -> bool: + var clazz_type := typeof(clazz) + if clazz_type != TYPE_OBJECT and clazz_type != TYPE_STRING: + push_error("Invalid clazz type is used") + return false + # is PackedScene + if GdObjects.is_scene(clazz): + return true + if GdObjects.is_native_class(clazz): + return true + # verify class type + if GdObjects.is_object(clazz): + if GdObjects.is_instance(clazz): + if push_errors: + push_error("It is not allowed to mock an instance '%s', use class name instead, Read 'Mocker' documentation for details" % clazz) + return false + + if not GdObjects.can_be_instantiate(clazz): + if push_errors: + push_error("Can't create a mockable instance for class '%s'" % clazz) + return false + return true + # verify by class name checked registered classes + var clazz_name: String = clazz + if ClassDB.class_exists(clazz_name): + if Engine.has_singleton(clazz_name): + if push_errors: + push_error("Mocking a singelton class '%s' is not allowed! Read 'Mocker' documentation for details" % clazz_name) + return false + if not ClassDB.can_instantiate(clazz_name): + if push_errors: + push_error("Mocking class '%s' is not allowed it cannot be instantiated!" % clazz_name) + return false + # exclude classes where name starts with a underscore + if clazz_name.find("_") == 0: + if push_errors: + push_error("Can't create a mockable instance for protected class '%s'" % clazz_name) + return false + return true + # at least try to load as a script + var clazz_path := clazz_name + if not FileAccess.file_exists(clazz_path): + if push_errors: + push_error("'%s' cannot be mocked for the specified resource path, the resource does not exist" % clazz_name) + return false + # finally verify is a script resource + var resource := load(clazz_path) + if resource == null: + if push_errors: + push_error("'%s' cannot be mocked the script cannot be loaded." % clazz_name) + return false + # finally check is extending from script + return GdObjects.is_script(resource) or GdObjects.is_scene(resource) + + +static func register_auto_free(obj :Variant) -> Variant: + return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj) diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid new file mode 100644 index 0000000..8380b27 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid @@ -0,0 +1 @@ +uid://bx8ykipeol8m1 diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd new file mode 100644 index 0000000..774ca3e --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd @@ -0,0 +1,143 @@ +class_name DoubledMockClassSourceClassName + +################################################################################ +# internal mocking stuff +################################################################################ + +const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}" + + +class GdUnitMockDoublerState: + const __SOURCE_CLASS := "{gdunit_source_class}" + + var excluded_methods := PackedStringArray() + var working_mode := GdUnitMock.RETURN_DEFAULTS + var is_prepare_return := false + var return_values := Dictionary() + var return_value: Variant = null + + + func _init(working_mode_ := GdUnitMock.RETURN_DEFAULTS) -> void: + working_mode = working_mode_ + + +var __mock_state := GdUnitMockDoublerState.new() +@warning_ignore("unused_private_class_variable") +var __verifier_instance := GdUnitObjectInteractionsVerifier.new() + + +func __init(__script: GDScript, mock_working_mode: String) -> void: + super.set_script(__script) + __init_doubler() + __mock_state.working_mode = mock_working_mode + + +static func __doubler_state() -> GdUnitMockDoublerState: + if Engine.has_meta(__INSTANCE_ID): + return Engine.get_meta(__INSTANCE_ID).__mock_state + return null + + +func __init_doubler() -> void: + Engine.set_meta(__INSTANCE_ID, self) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID): + Engine.remove_meta(__INSTANCE_ID) + + +static func __get_verifier() -> GdUnitObjectInteractionsVerifier: + return Engine.get_meta(__INSTANCE_ID).__verifier_instance + + +static func __is_prepare_return_value() -> bool: + return __doubler_state().is_prepare_return + + +static func __sort_by_argument_matcher(__left_args: Array, __right_args: Array) -> bool: + for __index in __left_args.size(): + var __larg: Variant = __left_args[__index] + if __larg is GdUnitArgumentMatcher: + return false + return true + + +# we need to sort by matcher arguments so that they are all at the end of the list +static func __sort_dictionary(__unsorted_args: Dictionary) -> Dictionary: + # only need to sort if contains more than one entry + if __unsorted_args.size() <= 1: + return __unsorted_args + var __sorted_args: Array = __unsorted_args.keys() + __sorted_args.sort_custom(__sort_by_argument_matcher) + var __sorted_result := {} + for __index in __sorted_args.size(): + var key :Variant = __sorted_args[__index] + __sorted_result[key] = __unsorted_args[key] + return __sorted_result + + +static func __save_function_return_value(__func_name: String, __func_args: Array) -> void: + var doubler_state := __doubler_state() + var mocked_return_value_by_args: Dictionary = doubler_state.return_values.get(__func_name, {}) + + mocked_return_value_by_args[__func_args] = doubler_state.return_value + doubler_state.return_values[__func_name] = __sort_dictionary(mocked_return_value_by_args) + doubler_state.return_value = null + doubler_state.is_prepare_return = false + + +static func __is_mocked_args_match(__func_args: Array, __mocked_args: Array) -> bool: + var __is_matching := false + for __index in __mocked_args.size(): + var __fuction_args: Array = __mocked_args[__index] + if __func_args.size() != __fuction_args.size(): + continue + __is_matching = true + for __arg_index in __func_args.size(): + var __func_arg: Variant = __func_args[__arg_index] + var __mock_arg: Variant = __fuction_args[__arg_index] + if __mock_arg is GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + __is_matching = __is_matching and __mock_arg.is_match(__func_arg) + else: + __is_matching = __is_matching and typeof(__func_arg) == typeof(__mock_arg) and __func_arg == __mock_arg + if not __is_matching: + break + if __is_matching: + break + return __is_matching + + +static func __return_mock_value(__func_name: String, __func_args: Array, __default_return_value: Variant) -> Variant: + var doubler_state := __doubler_state() + if not doubler_state.return_values.has(__func_name): + return __default_return_value + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + for __index in __mocked_args.size(): + var __margs: Variant = __mocked_args[__index] + if __is_mocked_args_match(__func_args, [__margs]): + return doubler_state.return_values[__func_name][__margs] + return __default_return_value + + +static func __is_do_not_call_real_func(__func_name: String, __func_args := []) -> bool: + var doubler_state := __doubler_state() + var __is_call_real_func: bool = doubler_state.working_mode == GdUnitMock.CALL_REAL_FUNC and not doubler_state.excluded_methods.has(__func_name) + # do not call real funcions for mocked functions + if __is_call_real_func and doubler_state.return_values.has(__func_name): + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + return __is_mocked_args_match(__func_args, __mocked_args) + return !__is_call_real_func + + +func __exclude_method_call(exluded_methods: PackedStringArray) -> void: + __doubler_state().excluded_methods.append_array(exluded_methods) + + +func __do_return(mock_do_return_value: Variant) -> Object: + __doubler_state().return_value = mock_do_return_value + __doubler_state().is_prepare_return = true + return self diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid new file mode 100644 index 0000000..fbe29d3 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid @@ -0,0 +1 @@ +uid://bsuaeeb5m6lyd |
