chore(client): add gdUnit4 test framework addon
Vendor gdUnit4 for Godot 4 client testing (D-030). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cg23cba4yy6hr
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyfsf4wrxieed
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dknn4acbn3efv
|
||||
Reference in New Issue
Block a user