Adding log.gd

This commit is contained in:
Dan Baker 2024-05-02 09:36:31 +01:00
parent eb32d6614e
commit 4522259397
547 changed files with 46844 additions and 0 deletions

View file

@ -0,0 +1,101 @@
## Small helper tool to work with Godot Arrays
class_name GdArrayTools
extends RefCounted
const max_elements := 32
const ARRAY_TYPES := [
TYPE_ARRAY,
TYPE_PACKED_BYTE_ARRAY,
TYPE_PACKED_INT32_ARRAY,
TYPE_PACKED_INT64_ARRAY,
TYPE_PACKED_FLOAT32_ARRAY,
TYPE_PACKED_FLOAT64_ARRAY,
TYPE_PACKED_STRING_ARRAY,
TYPE_PACKED_VECTOR2_ARRAY,
TYPE_PACKED_VECTOR3_ARRAY,
TYPE_PACKED_COLOR_ARRAY
]
static func is_array_type(value) -> bool:
return is_type_array(typeof(value))
static func is_type_array(type :int) -> bool:
return type in ARRAY_TYPES
## Filters an array by given value[br]
## If the given value not an array it returns null, will remove all occurence of given value.
static func filter_value(array, value :Variant) -> Variant:
if not is_array_type(array):
return null
var filtered_array = array.duplicate()
var index :int = filtered_array.find(value)
while index != -1:
filtered_array.remove_at(index)
index = filtered_array.find(value)
return filtered_array
## Erases a value from given array by using equals(l,r) to find the element to erase
static func erase_value(array :Array, value) -> void:
for element in array:
if GdObjects.equals(element, value):
array.erase(element)
## Scans for the array build in type on a untyped array[br]
## Returns the buildin type by scan all values and returns the type if all values has the same type.
## If the values has different types TYPE_VARIANT is returend
static func scan_typed(array :Array) -> int:
if array.is_empty():
return TYPE_NIL
var actual_type := GdObjects.TYPE_VARIANT
for value in array:
var current_type := typeof(value)
if not actual_type in [GdObjects.TYPE_VARIANT, current_type]:
return GdObjects.TYPE_VARIANT
actual_type = current_type
return actual_type
## Converts given array into a string presentation.[br]
## This function is different to the original Godot str(<array>) implementation.
## The string presentaion contains fullquallified typed informations.
##[br]
## Examples:
## [codeblock]
## # will result in PackedString(["a", "b"])
## GdArrayTools.as_string(PackedStringArray("a", "b"))
## # will result in PackedString(["a", "b"])
## GdArrayTools.as_string(PackedColorArray(Color.RED, COLOR.GREEN))
## [/codeblock]
static func as_string(elements :Variant, encode_value := true) -> String:
if not is_array_type(elements):
return "ERROR: Not an Array Type!"
var delemiter = ", "
if elements == null:
return "<null>"
if elements.is_empty():
return "<empty>"
var prefix := _typeof_as_string(elements) if encode_value else ""
var formatted := ""
var index := 0
for element in elements:
if max_elements != -1 and index > max_elements:
return prefix + "[" + formatted + delemiter + "...]"
if formatted.length() > 0 :
formatted += delemiter
formatted += GdDefaultValueDecoder.decode(element) if encode_value else str(element)
index += 1
return prefix + "[" + formatted + "]"
static func _typeof_as_string(value :Variant) -> String:
var type := typeof(value)
# for untyped array we retun empty string
if type == TYPE_ARRAY:
return ""
return GdObjects.typeof_as_string(value)

View file

@ -0,0 +1,155 @@
# A tool to find differences between two objects
class_name GdDiffTool
extends RefCounted
const DIV_ADD :int = 214
const DIV_SUB :int = 215
static func _diff(lb: PackedByteArray, rb: PackedByteArray, lookup: Array, ldiff: Array, rdiff: Array):
var loffset = lb.size()
var roffset = rb.size()
while true:
#if last character of X and Y matches
if loffset > 0 && roffset > 0 && lb[loffset - 1] == rb[roffset - 1]:
loffset -= 1
roffset -= 1
ldiff.push_front(lb[loffset])
rdiff.push_front(rb[roffset])
continue
#current character of Y is not present in X
else: if (roffset > 0 && (loffset == 0 || lookup[loffset][roffset - 1] >= lookup[loffset - 1][roffset])):
roffset -= 1
ldiff.push_front(rb[roffset])
ldiff.push_front(DIV_ADD)
rdiff.push_front(rb[roffset])
rdiff.push_front(DIV_SUB)
continue
#current character of X is not present in Y
else: if (loffset > 0 && (roffset == 0 || lookup[loffset][roffset - 1] < lookup[loffset - 1][roffset])):
loffset -= 1
ldiff.push_front(lb[loffset])
ldiff.push_front(DIV_SUB)
rdiff.push_front(lb[loffset])
rdiff.push_front(DIV_ADD)
continue
break
# lookup[i][j] stores the length of LCS of substring X[0..i-1], Y[0..j-1]
static func _createLookUp(lb: PackedByteArray, rb: PackedByteArray) -> Array:
var lookup := Array()
lookup.resize(lb.size() + 1)
for i in lookup.size():
var x = []
x.resize(rb.size() + 1)
lookup[i] = x
return lookup
static func _buildLookup(lb: PackedByteArray, rb: PackedByteArray) -> Array:
var lookup := _createLookUp(lb, rb)
# first column of the lookup table will be all 0
for i in lookup.size():
lookup[i][0] = 0
# first row of the lookup table will be all 0
for j in lookup[0].size():
lookup[0][j] = 0
# fill the lookup table in bottom-up manner
for i in range(1, lookup.size()):
for j in range(1, lookup[0].size()):
# if current character of left and right matches
if lb[i - 1] == rb[j - 1]:
lookup[i][j] = lookup[i - 1][j - 1] + 1;
# else if current character of left and right don't match
else:
lookup[i][j] = max(lookup[i - 1][j], lookup[i][j - 1]);
return lookup
static func string_diff(left, right) -> Array[PackedByteArray]:
var lb := PackedByteArray() if left == null else str(left).to_ascii_buffer()
var rb := PackedByteArray() if right == null else str(right).to_ascii_buffer()
var ldiff := Array()
var rdiff := Array()
var lookup := _buildLookup(lb, rb);
_diff(lb, rb, lookup, ldiff, rdiff)
return [PackedByteArray(ldiff), PackedByteArray(rdiff)]
# prototype
static func longestCommonSubsequence(text1 :String, text2 :String) -> PackedStringArray:
var text1Words := text1.split(" ")
var text2Words := text2.split(" ")
var text1WordCount := text1Words.size()
var text2WordCount := text2Words.size()
var solutionMatrix := Array()
for i in text1WordCount+1:
var ar := Array()
for n in text2WordCount+1:
ar.append(0)
solutionMatrix.append(ar)
for i in range(text1WordCount-1, 0, -1):
for j in range(text2WordCount-1, 0, -1):
if text1Words[i] == text2Words[j]:
solutionMatrix[i][j] = solutionMatrix[i + 1][j + 1] + 1;
else:
solutionMatrix[i][j] = max(solutionMatrix[i + 1][j], solutionMatrix[i][j + 1]);
var i = 0
var j = 0
var lcsResultList := PackedStringArray();
while (i < text1WordCount && j < text2WordCount):
if text1Words[i] == text2Words[j]:
lcsResultList.append(text2Words[j])
i += 1
j += 1
else: if (solutionMatrix[i + 1][j] >= solutionMatrix[i][j + 1]):
i += 1
else:
j += 1
return lcsResultList
static func markTextDifferences(text1 :String, text2 :String, lcsList :PackedStringArray, insertColor :Color, deleteColor:Color) -> String:
var stringBuffer = ""
if text1 == null and lcsList == null:
return stringBuffer
var text1Words := text1.split(" ")
var text2Words := text2.split(" ")
var i = 0
var j = 0
var word1LastIndex = 0
var word2LastIndex = 0
for k in lcsList.size():
while i < text1Words.size() and j < text2Words.size():
if text1Words[i] == lcsList[k] and text2Words[j] == lcsList[k]:
stringBuffer += "<SPAN>" + lcsList[k] + " </SPAN>"
word1LastIndex = i + 1
word2LastIndex = j + 1
i = text1Words.size()
j = text2Words.size()
else: if text1Words[i] != lcsList[k]:
while i < text1Words.size() and text1Words[i] != lcsList[k]:
stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[i] + " </SPAN>"
i += 1
else: if text2Words[j] != lcsList[k]:
while j < text2Words.size() and text2Words[j] != lcsList[k]:
stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[j] + " </SPAN>"
j += 1
i = word1LastIndex
j = word2LastIndex
while word1LastIndex < text1Words.size():
stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[word1LastIndex] + " </SPAN>"
word1LastIndex += 1
while word2LastIndex < text2Words.size():
stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[word2LastIndex] + " </SPAN>"
word2LastIndex += 1
return stringBuffer

View file

@ -0,0 +1,191 @@
class_name GdFunctionDoubler
extends RefCounted
const DEFAULT_TYPED_RETURN_VALUES := {
TYPE_NIL: "null",
TYPE_BOOL: "false",
TYPE_INT: "0",
TYPE_FLOAT: "0.0",
TYPE_STRING: "\"\"",
TYPE_STRING_NAME: "&\"\"",
TYPE_VECTOR2: "Vector2.ZERO",
TYPE_VECTOR2I: "Vector2i.ZERO",
TYPE_RECT2: "Rect2()",
TYPE_RECT2I: "Rect2i()",
TYPE_VECTOR3: "Vector3.ZERO",
TYPE_VECTOR3I: "Vector3i.ZERO",
TYPE_VECTOR4: "Vector4.ZERO",
TYPE_VECTOR4I: "Vector4i.ZERO",
TYPE_TRANSFORM2D: "Transform2D()",
TYPE_PLANE: "Plane()",
TYPE_QUATERNION: "Quaternion()",
TYPE_AABB: "AABB()",
TYPE_BASIS: "Basis()",
TYPE_TRANSFORM3D: "Transform3D()",
TYPE_PROJECTION: "Projection()",
TYPE_COLOR: "Color()",
TYPE_NODE_PATH: "NodePath()",
TYPE_RID: "RID()",
TYPE_OBJECT: "null",
TYPE_CALLABLE: "Callable()",
TYPE_SIGNAL: "Signal()",
TYPE_DICTIONARY: "Dictionary()",
TYPE_ARRAY: "Array()",
TYPE_PACKED_BYTE_ARRAY: "PackedByteArray()",
TYPE_PACKED_INT32_ARRAY: "PackedInt32Array()",
TYPE_PACKED_INT64_ARRAY: "PackedInt64Array()",
TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array()",
TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array()",
TYPE_PACKED_STRING_ARRAY: "PackedStringArray()",
TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array()",
TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array()",
TYPE_PACKED_COLOR_ARRAY: "PackedColorArray()",
GdObjects.TYPE_VARIANT: "null",
GdObjects.TYPE_ENUM: "0"
}
# @GlobalScript enums
# needs to manually map because of https://github.com/godotengine/godot/issues/73835
const DEFAULT_ENUM_RETURN_VALUES = {
"Side" : "SIDE_LEFT",
"Corner" : "CORNER_TOP_LEFT",
"Orientation" : "HORIZONTAL",
"ClockDirection" : "CLOCKWISE",
"HorizontalAlignment" : "HORIZONTAL_ALIGNMENT_LEFT",
"VerticalAlignment" : "VERTICAL_ALIGNMENT_TOP",
"InlineAlignment" : "INLINE_ALIGNMENT_TOP_TO",
"EulerOrder" : "EULER_ORDER_XYZ",
"Key" : "KEY_NONE",
"KeyModifierMask" : "KEY_CODE_MASK",
"MouseButton" : "MOUSE_BUTTON_NONE",
"MouseButtonMask" : "MOUSE_BUTTON_MASK_LEFT",
"JoyButton" : "JOY_BUTTON_INVALID",
"JoyAxis" : "JOY_AXIS_INVALID",
"MIDIMessage" : "MIDI_MESSAGE_NONE",
"Error" : "OK",
"PropertyHint" : "PROPERTY_HINT_NONE",
"Variant.Type" : "TYPE_NIL",
}
var _push_errors :String
# Determine the enum default by reflection
static func get_enum_default(value :String) -> Variant:
var script := GDScript.new()
script.source_code = """
extends Resource
static func get_enum_default() -> Variant:
return %s.values()[0]
""".dedent() % value
script.reload()
script.source_code
return script.new().call("get_enum_default")
static func default_return_value(func_descriptor :GdFunctionDescriptor) -> String:
var return_type :Variant = func_descriptor.return_type()
if return_type == GdObjects.TYPE_ENUM:
var enum_class := func_descriptor._return_class
var enum_path := enum_class.split(".")
if enum_path.size() >= 2:
var keys := ClassDB.class_get_enum_constants(enum_path[0], enum_path[1])
if not keys.is_empty():
return "%s.%s" % [enum_path[0], keys[0]]
var enum_value := get_enum_default(enum_class)
if enum_value != null:
return str(enum_value)
# we need fallback for @GlobalScript enums,
return DEFAULT_ENUM_RETURN_VALUES.get(func_descriptor._return_class, "0")
return DEFAULT_TYPED_RETURN_VALUES.get(return_type, "invalid")
func _init(push_errors :bool = false):
_push_errors = "true" if push_errors else "false"
for type_key in TYPE_MAX:
if not DEFAULT_TYPED_RETURN_VALUES.has(type_key):
push_error("missing default definitions! Expexting %d bud is %d" % [DEFAULT_TYPED_RETURN_VALUES.size(), TYPE_MAX])
prints("missing default definition for type", type_key)
assert(DEFAULT_TYPED_RETURN_VALUES.has(type_key), "Missing Type default definition!")
@warning_ignore("unused_parameter")
func get_template(return_type :Variant, is_vararg :bool) -> String:
push_error("Must be implemented!")
return ""
func double(func_descriptor :GdFunctionDescriptor) -> PackedStringArray:
var func_signature := func_descriptor.typeless()
var is_static := func_descriptor.is_static()
var is_vararg := func_descriptor.is_vararg()
var is_coroutine := func_descriptor.is_coroutine()
var func_name := func_descriptor.name()
var args := func_descriptor.args()
var varargs := func_descriptor.varargs()
var return_value := GdFunctionDoubler.default_return_value(func_descriptor)
var arg_names := extract_arg_names(args)
var vararg_names := extract_arg_names(varargs)
# save original constructor arguments
if func_name == "_init":
var constructor_args := ",".join(GdFunctionDoubler.extract_constructor_args(args))
var constructor := "func _init(%s) -> void:\n super(%s)\n pass\n" % [constructor_args, ", ".join(arg_names)]
return constructor.split("\n")
var double_src := ""
double_src += '@warning_ignore("untyped_declaration")\n' if Engine.get_version_info().hex >= 0x40200 else '\n'
if func_descriptor.is_engine():
double_src += '@warning_ignore("native_method_override")\n'
if func_descriptor.return_type() == GdObjects.TYPE_ENUM:
double_src += '@warning_ignore("int_as_enum_without_match")\n'
double_src += '@warning_ignore("int_as_enum_without_cast")\n'
double_src += '@warning_ignore("shadowed_variable")\n'
double_src += func_signature
# fix to unix format, this is need when the template is edited under windows than the template is stored with \r\n
var func_template := get_template(func_descriptor.return_type(), is_vararg).replace("\r\n", "\n")
double_src += func_template\
.replace("$(arguments)", ", ".join(arg_names))\
.replace("$(varargs)", ", ".join(vararg_names))\
.replace("$(await)", GdFunctionDoubler.await_is_coroutine(is_coroutine)) \
.replace("$(func_name)", func_name )\
.replace("${default_return_value}", return_value)\
.replace("$(push_errors)", _push_errors)
if is_static:
double_src = double_src.replace("$(instance)", "__instance().")
else:
double_src = double_src.replace("$(instance)", "")
return double_src.split("\n")
func extract_arg_names(argument_signatures :Array[GdFunctionArgument]) -> PackedStringArray:
var arg_names := PackedStringArray()
for arg in argument_signatures:
arg_names.append(arg._name)
return arg_names
static func extract_constructor_args(args :Array) -> PackedStringArray:
var constructor_args := PackedStringArray()
for arg in args:
var a := arg as GdFunctionArgument
var arg_name := a._name
var default_value = get_default(a)
if default_value == "null":
constructor_args.append(arg_name + ":Variant=" + default_value)
else:
constructor_args.append(arg_name + ":=" + default_value)
return constructor_args
static func get_default(arg :GdFunctionArgument) -> String:
if arg.has_default():
return arg.value_as_string()
else:
return DEFAULT_TYPED_RETURN_VALUES.get(arg.type(), "null")
static func await_is_coroutine(is_coroutine :bool) -> String:
return "await " if is_coroutine else ""

View file

@ -0,0 +1,686 @@
# This is a helper class to compare two objects by equals
class_name GdObjects
extends Resource
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const TYPE_VOID = TYPE_MAX + 1000
const TYPE_VARARG = TYPE_MAX + 1001
const TYPE_VARIANT = TYPE_MAX + 1002
const TYPE_FUNC = TYPE_MAX + 1003
const TYPE_FUZZER = TYPE_MAX + 1004
const TYPE_NODE = TYPE_MAX + 2001
# missing Godot types
const TYPE_CONTROL = TYPE_MAX + 2002
const TYPE_CANVAS = TYPE_MAX + 2003
const TYPE_ENUM = TYPE_MAX + 2004
# used as default value for varargs
const TYPE_VARARG_PLACEHOLDER_VALUE = "__null__"
const TYPE_AS_STRING_MAPPINGS := {
TYPE_NIL: "null",
TYPE_BOOL: "bool",
TYPE_INT: "int",
TYPE_FLOAT: "float",
TYPE_STRING: "String",
TYPE_VECTOR2: "Vector2",
TYPE_VECTOR2I: "Vector2i",
TYPE_RECT2: "Rect2",
TYPE_RECT2I: "Rect2i",
TYPE_VECTOR3: "Vector3",
TYPE_VECTOR3I: "Vector3i",
TYPE_TRANSFORM2D: "Transform2D",
TYPE_VECTOR4: "Vector4",
TYPE_VECTOR4I: "Vector4i",
TYPE_PLANE: "Plane",
TYPE_QUATERNION: "Quaternion",
TYPE_AABB: "AABB",
TYPE_BASIS: "Basis",
TYPE_TRANSFORM3D: "Transform3D",
TYPE_PROJECTION: "Projection",
TYPE_COLOR: "Color",
TYPE_STRING_NAME: "StringName",
TYPE_NODE_PATH: "NodePath",
TYPE_RID: "RID",
TYPE_OBJECT: "Object",
TYPE_CALLABLE: "Callable",
TYPE_SIGNAL: "Signal",
TYPE_DICTIONARY: "Dictionary",
TYPE_ARRAY: "Array",
TYPE_PACKED_BYTE_ARRAY: "PackedByteArray",
TYPE_PACKED_INT32_ARRAY: "PackedInt32Array",
TYPE_PACKED_INT64_ARRAY: "PackedInt64Array",
TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array",
TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array",
TYPE_PACKED_STRING_ARRAY: "PackedStringArray",
TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array",
TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array",
TYPE_PACKED_COLOR_ARRAY: "PackedColorArray",
TYPE_VOID: "void",
TYPE_VARARG: "VarArg",
TYPE_FUNC: "Func",
TYPE_FUZZER: "Fuzzer",
TYPE_VARIANT: "Variant"
}
const NOTIFICATION_AS_STRING_MAPPINGS := {
TYPE_OBJECT: {
Object.NOTIFICATION_POSTINITIALIZE : "POSTINITIALIZE",
Object.NOTIFICATION_PREDELETE: "PREDELETE",
EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED: "EDITOR_SETTINGS_CHANGED",
},
TYPE_NODE: {
Node.NOTIFICATION_ENTER_TREE : "ENTER_TREE",
Node.NOTIFICATION_EXIT_TREE: "EXIT_TREE",
Node.NOTIFICATION_MOVED_IN_PARENT: "MOVED_IN_PARENT",
Node.NOTIFICATION_READY: "READY",
Node.NOTIFICATION_PAUSED: "PAUSED",
Node.NOTIFICATION_UNPAUSED: "UNPAUSED",
Node.NOTIFICATION_PHYSICS_PROCESS: "PHYSICS_PROCESS",
Node.NOTIFICATION_PROCESS: "PROCESS",
Node.NOTIFICATION_PARENTED: "PARENTED",
Node.NOTIFICATION_UNPARENTED: "UNPARENTED",
Node.NOTIFICATION_SCENE_INSTANTIATED: "INSTANCED",
Node.NOTIFICATION_DRAG_BEGIN: "DRAG_BEGIN",
Node.NOTIFICATION_DRAG_END: "DRAG_END",
Node.NOTIFICATION_PATH_RENAMED: "PATH_CHANGED",
Node.NOTIFICATION_INTERNAL_PROCESS: "INTERNAL_PROCESS",
Node.NOTIFICATION_INTERNAL_PHYSICS_PROCESS: "INTERNAL_PHYSICS_PROCESS",
Node.NOTIFICATION_POST_ENTER_TREE: "POST_ENTER_TREE",
Node.NOTIFICATION_WM_MOUSE_ENTER: "WM_MOUSE_ENTER",
Node.NOTIFICATION_WM_MOUSE_EXIT: "WM_MOUSE_EXIT",
Node.NOTIFICATION_APPLICATION_FOCUS_IN: "WM_FOCUS_IN",
Node.NOTIFICATION_APPLICATION_FOCUS_OUT: "WM_FOCUS_OUT",
#Node.NOTIFICATION_WM_QUIT_REQUEST: "WM_QUIT_REQUEST",
Node.NOTIFICATION_WM_GO_BACK_REQUEST: "WM_GO_BACK_REQUEST",
Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT: "WM_UNFOCUS_REQUEST",
Node.NOTIFICATION_OS_MEMORY_WARNING: "OS_MEMORY_WARNING",
Node.NOTIFICATION_TRANSLATION_CHANGED: "TRANSLATION_CHANGED",
Node.NOTIFICATION_WM_ABOUT: "WM_ABOUT",
Node.NOTIFICATION_CRASH: "CRASH",
Node.NOTIFICATION_OS_IME_UPDATE: "OS_IME_UPDATE",
Node.NOTIFICATION_APPLICATION_RESUMED: "APP_RESUMED",
Node.NOTIFICATION_APPLICATION_PAUSED: "APP_PAUSED",
Node3D.NOTIFICATION_TRANSFORM_CHANGED: "TRANSFORM_CHANGED",
Node3D.NOTIFICATION_ENTER_WORLD: "ENTER_WORLD",
Node3D.NOTIFICATION_EXIT_WORLD: "EXIT_WORLD",
Node3D.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED",
Skeleton3D.NOTIFICATION_UPDATE_SKELETON: "UPDATE_SKELETON",
CanvasItem.NOTIFICATION_DRAW: "DRAW",
CanvasItem.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED",
CanvasItem.NOTIFICATION_ENTER_CANVAS: "ENTER_CANVAS",
CanvasItem.NOTIFICATION_EXIT_CANVAS: "EXIT_CANVAS",
#Popup.NOTIFICATION_POST_POPUP: "POST_POPUP",
#Popup.NOTIFICATION_POPUP_HIDE: "POPUP_HIDE",
},
TYPE_CONTROL : {
Container.NOTIFICATION_SORT_CHILDREN: "SORT_CHILDREN",
Control.NOTIFICATION_RESIZED: "RESIZED",
Control.NOTIFICATION_MOUSE_ENTER: "MOUSE_ENTER",
Control.NOTIFICATION_MOUSE_EXIT: "MOUSE_EXIT",
Control.NOTIFICATION_FOCUS_ENTER: "FOCUS_ENTER",
Control.NOTIFICATION_FOCUS_EXIT: "FOCUS_EXIT",
Control.NOTIFICATION_THEME_CHANGED: "THEME_CHANGED",
#Control.NOTIFICATION_MODAL_CLOSE: "MODAL_CLOSE",
Control.NOTIFICATION_SCROLL_BEGIN: "SCROLL_BEGIN",
Control.NOTIFICATION_SCROLL_END: "SCROLL_END",
}
}
enum COMPARE_MODE {
OBJECT_REFERENCE,
PARAMETER_DEEP_TEST
}
# prototype of better object to dictionary
static func obj2dict(obj :Object, hashed_objects := Dictionary()) -> Dictionary:
if obj == null:
return {}
var clazz_name := obj.get_class()
var dict := Dictionary()
var clazz_path := ""
if is_instance_valid(obj) and obj.get_script() != null:
var d := inst_to_dict(obj)
clazz_path = d["@path"]
if d["@subpath"] != NodePath(""):
clazz_name = d["@subpath"]
dict["@inner_class"] = true
else:
clazz_name = clazz_path.get_file().replace(".gd", "")
dict["@path"] = clazz_path
for property in obj.get_property_list():
var property_name = property["name"]
var property_type = property["type"]
var property_value = obj.get(property_name)
if property_value is GDScript or property_value is Callable:
continue
if (property["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE|PROPERTY_USAGE_DEFAULT
and not property["usage"] & PROPERTY_USAGE_CATEGORY
and not property["usage"] == 0):
if property_type == TYPE_OBJECT:
# prevent recursion
if hashed_objects.has(obj):
dict[property_name] = str(property_value)
continue
hashed_objects[obj] = true
dict[property_name] = obj2dict(property_value, hashed_objects)
else:
dict[property_name] = property_value
return {"%s" % clazz_name : dict}
static func equals(obj_a :Variant, obj_b :Variant, case_sensitive :bool = false, compare_mode :COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
return _equals(obj_a, obj_b, case_sensitive, compare_mode, [], 0)
static func equals_sorted(obj_a :Array, obj_b :Array, case_sensitive :bool = false, compare_mode :COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
var a := obj_a.duplicate()
var b := obj_b.duplicate()
a.sort()
b.sort()
return equals(a, b, case_sensitive, compare_mode)
static func _equals(obj_a :Variant, obj_b :Variant, case_sensitive :bool, compare_mode :COMPARE_MODE, deep_stack :Array, stack_depth :int ) -> bool:
var type_a := typeof(obj_a)
var type_b := typeof(obj_b)
if stack_depth > 32:
prints("stack_depth", stack_depth, deep_stack)
push_error("GdUnit equals has max stack deep reached!")
return false
# use argument matcher if requested
if is_instance_valid(obj_a) and obj_a is GdUnitArgumentMatcher:
return (obj_a as GdUnitArgumentMatcher).is_match(obj_b)
if is_instance_valid(obj_b) and obj_b is GdUnitArgumentMatcher:
return (obj_b as GdUnitArgumentMatcher).is_match(obj_a)
stack_depth += 1
# fast fail is different types
if not _is_type_equivalent(type_a, type_b):
return false
# is same instance
if obj_a == obj_b:
return true
# handle null values
if obj_a == null and obj_b != null:
return false
if obj_b == null and obj_a != null:
return false
match type_a:
TYPE_OBJECT:
if deep_stack.has(obj_a) or deep_stack.has(obj_b):
return true
deep_stack.append(obj_a)
deep_stack.append(obj_b)
if compare_mode == COMPARE_MODE.PARAMETER_DEEP_TEST:
# fail fast
if not is_instance_valid(obj_a) or not is_instance_valid(obj_b):
return false
if obj_a.get_class() != obj_b.get_class():
return false
var a = obj2dict(obj_a)
var b = obj2dict(obj_b)
return _equals(a, b, case_sensitive, compare_mode, deep_stack, stack_depth)
return obj_a == obj_b
TYPE_ARRAY:
if obj_a.size() != obj_b.size():
return false
for index in obj_a.size():
if not _equals(obj_a[index], obj_b[index], case_sensitive, compare_mode, deep_stack, stack_depth):
return false
return true
TYPE_DICTIONARY:
if obj_a.size() != obj_b.size():
return false
for key in obj_a.keys():
var value_a = obj_a[key] if obj_a.has(key) else null
var value_b = obj_b[key] if obj_b.has(key) else null
if not _equals(value_a, value_b, case_sensitive, compare_mode, deep_stack, stack_depth):
return false
return true
TYPE_STRING:
if case_sensitive:
return obj_a.to_lower() == obj_b.to_lower()
else:
return obj_a == obj_b
return obj_a == obj_b
@warning_ignore("shadowed_variable_base_class")
static func notification_as_string(instance :Variant, notification :int) -> String:
var error := "Unknown notification: '%s' at instance: %s" % [notification, instance]
if instance is Node:
return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].get(notification, error)
if instance is Control:
return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].get(notification, error)
return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_OBJECT].get(notification, error)
static func string_to_type(value :String) -> int:
for type in TYPE_AS_STRING_MAPPINGS.keys():
if TYPE_AS_STRING_MAPPINGS.get(type) == value:
return type
return TYPE_NIL
static func to_camel_case(value :String) -> String:
var p := to_pascal_case(value)
if not p.is_empty():
p[0] = p[0].to_lower()
return p
static func to_pascal_case(value :String) -> String:
return value.capitalize().replace(" ", "")
static func to_snake_case(value :String) -> String:
var result = PackedStringArray()
for ch in value:
var lower_ch = ch.to_lower()
if ch != lower_ch and result.size() > 1:
result.append('_')
result.append(lower_ch)
return ''.join(result)
static func is_snake_case(value :String) -> bool:
for ch in value:
if ch == '_':
continue
if ch == ch.to_upper():
return false
return true
static func type_as_string(type :int) -> String:
return TYPE_AS_STRING_MAPPINGS.get(type, "Variant")
static func typeof_as_string(value :Variant) -> String:
return TYPE_AS_STRING_MAPPINGS.get(typeof(value), "Unknown type")
static func all_types() -> PackedInt32Array:
return PackedInt32Array(TYPE_AS_STRING_MAPPINGS.keys())
static func string_as_typeof(type_name :String) -> int:
var type :Variant = TYPE_AS_STRING_MAPPINGS.find_key(type_name)
return type if type != null else TYPE_VARIANT
static func is_primitive_type(value :Variant) -> bool:
return typeof(value) in [TYPE_BOOL, TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_FLOAT]
static func _is_type_equivalent(type_a :int, type_b :int) -> bool:
# don't test for TYPE_STRING_NAME equivalenz
if type_a == TYPE_STRING_NAME or type_b == TYPE_STRING_NAME:
return true
if GdUnitSettings.is_strict_number_type_compare():
return type_a == type_b
return (
(type_a == TYPE_FLOAT and type_b == TYPE_INT)
or (type_a == TYPE_INT and type_b == TYPE_FLOAT)
or type_a == type_b)
static func is_engine_type(value :Object) -> bool:
if value is GDScript or value is ScriptExtension:
return false
return value.is_class("GDScriptNativeClass")
static func is_type(value :Variant) -> bool:
# is an build-in type
if typeof(value) != TYPE_OBJECT:
return false
# is a engine class type
if is_engine_type(value):
return true
# is a custom class type
if value is GDScript and value.can_instantiate():
return true
return false
static func _is_same(left :Variant, right :Variant) -> bool:
var left_type := -1 if left == null else typeof(left)
var right_type := -1 if right == null else typeof(right)
# if typ different can't be the same
if left_type != right_type:
return false
if left_type == TYPE_OBJECT and right_type == TYPE_OBJECT:
return left.get_instance_id() == right.get_instance_id()
return equals(left, right)
static func is_object(value :Variant) -> bool:
return typeof(value) == TYPE_OBJECT
static func is_script(value :Variant) -> bool:
return is_object(value) and value is Script
static func is_test_suite(script :Script) -> bool:
return is_gd_testsuite(script) or GdUnit4CSharpApiLoader.is_test_suite(script.resource_path)
static func is_native_class(value :Variant) -> bool:
return is_object(value) and is_engine_type(value)
static func is_scene(value :Variant) -> bool:
return is_object(value) and value is PackedScene
static func is_scene_resource_path(value :Variant) -> bool:
return value is String and value.ends_with(".tscn")
static func is_gd_script(script :Script) -> bool:
return script is GDScript
static func is_cs_script(script :Script) -> bool:
# we need to check by stringify name because checked non mono Godot the class CSharpScript is not available
return str(script).find("CSharpScript") != -1
static func is_gd_testsuite(script :Script) -> bool:
if is_gd_script(script):
var stack := [script]
while not stack.is_empty():
var current := stack.pop_front() as Script
var base := current.get_base_script() as Script
if base != null:
if base.resource_path.find("GdUnitTestSuite") != -1:
return true
stack.push_back(base)
return false
static func is_singleton(value :Variant) -> bool:
if not is_instance_valid(value) or is_native_class(value):
return false
for name in Engine.get_singleton_list():
if value.is_class(name):
return true
return false
static func is_instance(value :Variant) -> bool:
if not is_instance_valid(value) or is_native_class(value):
return false
if is_script(value) and value.get_instance_base_type() == "":
return true
if is_scene(value):
return true
return not value.has_method('new') and not value.has_method('instance')
# only object form type Node and attached filename
static func is_instance_scene(instance :Variant) -> bool:
if instance is Node:
var node := instance as Node
return node.get_scene_file_path() != null and not node.get_scene_file_path().is_empty()
return false
static func can_be_instantiate(obj :Variant) -> bool:
if not obj or is_engine_type(obj):
return false
return obj.has_method("new")
static func create_instance(clazz :Variant) -> GdUnitResult:
match typeof(clazz):
TYPE_OBJECT:
# test is given clazz already an instance
if is_instance(clazz):
return GdUnitResult.success(clazz)
return GdUnitResult.success(clazz.new())
TYPE_STRING:
if ClassDB.class_exists(clazz):
if Engine.has_singleton(clazz):
return GdUnitResult.error("Not allowed to create a instance for singelton '%s'." % clazz)
if not ClassDB.can_instantiate(clazz):
return GdUnitResult.error("Can't instance Engine class '%s'." % clazz)
return GdUnitResult.success(ClassDB.instantiate(clazz))
else:
var clazz_path :String = extract_class_path(clazz)[0]
if not FileAccess.file_exists(clazz_path):
return GdUnitResult.error("Class '%s' not found." % clazz)
var script := load(clazz_path)
if script != null:
return GdUnitResult.success(script.new())
else:
return GdUnitResult.error("Can't create instance for '%s'." % clazz)
return GdUnitResult.error("Can't create instance for class '%s'." % clazz)
static func extract_class_path(clazz :Variant) -> PackedStringArray:
var clazz_path := PackedStringArray()
if clazz is String:
clazz_path.append(clazz)
return clazz_path
if is_instance(clazz):
# is instance a script instance?
var script := clazz.script as GDScript
if script != null:
return extract_class_path(script)
return clazz_path
if clazz is GDScript:
if not clazz.resource_path.is_empty():
clazz_path.append(clazz.resource_path)
return clazz_path
# if not found we go the expensive way and extract the path form the script by creating an instance
var arg_list := build_function_default_arguments(clazz, "_init")
var instance = clazz.callv("new", arg_list)
var clazz_info := inst_to_dict(instance)
GdUnitTools.free_instance(instance)
clazz_path.append(clazz_info["@path"])
if clazz_info.has("@subpath"):
var sub_path :String = clazz_info["@subpath"]
if not sub_path.is_empty():
var sub_paths := sub_path.split("/")
clazz_path += sub_paths
return clazz_path
return clazz_path
static func extract_class_name_from_class_path(clazz_path :PackedStringArray) -> String:
var base_clazz := clazz_path[0]
# return original class name if engine class
if ClassDB.class_exists(base_clazz):
return base_clazz
var clazz_name := to_pascal_case(base_clazz.get_basename().get_file())
for path_index in range(1, clazz_path.size()):
clazz_name += "." + clazz_path[path_index]
return clazz_name
static func extract_class_name(clazz :Variant) -> GdUnitResult:
if clazz == null:
return GdUnitResult.error("Can't extract class name form a null value.")
if is_instance(clazz):
# is instance a script instance?
var script := clazz.script as GDScript
if script != null:
return extract_class_name(script)
return GdUnitResult.success(clazz.get_class())
# extract name form full qualified class path
if clazz is String:
if ClassDB.class_exists(clazz):
return GdUnitResult.success(clazz)
var source_sript :Script = load(clazz)
var clazz_name :String = load("res://addons/gdUnit4/src/core/parse/GdScriptParser.gd").new().get_class_name(source_sript)
return GdUnitResult.success(to_pascal_case(clazz_name))
if is_primitive_type(clazz):
return GdUnitResult.error("Can't extract class name for an primitive '%s'" % type_as_string(typeof(clazz)))
if is_script(clazz):
if clazz.resource_path.is_empty():
var class_path := extract_class_name_from_class_path(extract_class_path(clazz))
return GdUnitResult.success(class_path);
return extract_class_name(clazz.resource_path)
# need to create an instance for a class typ the extract the class name
var instance :Variant = clazz.new()
if instance == null:
return GdUnitResult.error("Can't create a instance for class '%s'" % clazz)
var result := extract_class_name(instance)
GdUnitTools.free_instance(instance)
return result
static func extract_inner_clazz_names(clazz_name :String, script_path :PackedStringArray) -> PackedStringArray:
var inner_classes := PackedStringArray()
if ClassDB.class_exists(clazz_name):
return inner_classes
var script :GDScript = load(script_path[0])
var map := script.get_script_constant_map()
for key in map.keys():
var value = map.get(key)
if value is GDScript:
var class_path := extract_class_path(value)
inner_classes.append(class_path[1])
return inner_classes
static func extract_class_functions(clazz_name :String, script_path :PackedStringArray) -> Array:
if ClassDB.class_get_method_list(clazz_name):
return ClassDB.class_get_method_list(clazz_name)
if not FileAccess.file_exists(script_path[0]):
return Array()
var script :GDScript = load(script_path[0])
if script is GDScript:
# if inner class on class path we have to load the script from the script_constant_map
if script_path.size() == 2 and script_path[1] != "":
var inner_classes := script_path[1]
var map := script.get_script_constant_map()
script = map[inner_classes]
var clazz_functions :Array = script.get_method_list()
var base_clazz :String = script.get_instance_base_type()
if base_clazz:
return extract_class_functions(base_clazz, script_path)
return clazz_functions
return Array()
# scans all registert script classes for given <clazz_name>
# if the class is public in the global space than return true otherwise false
# public class means the script class is defined by 'class_name <name>'
static func is_public_script_class(clazz_name :String) -> bool:
var script_classes:Array[Dictionary] = ProjectSettings.get_global_class_list()
for class_info in script_classes:
if class_info.has("class"):
if class_info["class"] == clazz_name:
return true
return false
static func build_function_default_arguments(script :GDScript, func_name :String) -> Array:
var arg_list := Array()
for func_sig in script.get_script_method_list():
if func_sig["name"] == func_name:
var args :Array = func_sig["args"]
for arg in args:
var value_type := arg["type"] as int
var default_value = default_value_by_type(value_type)
arg_list.append(default_value)
return arg_list
return arg_list
static func default_value_by_type(type :int):
assert(type < TYPE_MAX)
assert(type >= 0)
match type:
TYPE_NIL: return null
TYPE_BOOL: return false
TYPE_INT: return 0
TYPE_FLOAT: return 0.0
TYPE_STRING: return ""
TYPE_VECTOR2: return Vector2.ZERO
TYPE_VECTOR2I: return Vector2i.ZERO
TYPE_VECTOR3: return Vector3.ZERO
TYPE_VECTOR3I: return Vector3i.ZERO
TYPE_VECTOR4: return Vector4.ZERO
TYPE_VECTOR4I: return Vector4i.ZERO
TYPE_RECT2: return Rect2()
TYPE_RECT2I: return Rect2i()
TYPE_TRANSFORM2D: return Transform2D()
TYPE_PLANE: return Plane()
TYPE_QUATERNION: return Quaternion()
TYPE_AABB: return AABB()
TYPE_BASIS: return Basis()
TYPE_TRANSFORM3D: return Transform3D()
TYPE_COLOR: return Color()
TYPE_NODE_PATH: return NodePath()
TYPE_RID: return RID()
TYPE_OBJECT: return null
TYPE_ARRAY: return []
TYPE_DICTIONARY: return {}
TYPE_PACKED_BYTE_ARRAY: return PackedByteArray()
TYPE_PACKED_COLOR_ARRAY: return PackedColorArray()
TYPE_PACKED_INT32_ARRAY: return PackedInt32Array()
TYPE_PACKED_INT64_ARRAY: return PackedInt64Array()
TYPE_PACKED_FLOAT32_ARRAY: return PackedFloat32Array()
TYPE_PACKED_FLOAT64_ARRAY: return PackedFloat64Array()
TYPE_PACKED_STRING_ARRAY: return PackedStringArray()
TYPE_PACKED_VECTOR2_ARRAY: return PackedVector2Array()
TYPE_PACKED_VECTOR3_ARRAY: return PackedVector3Array()
push_error("Can't determine a default value for type: '%s', Please create a Bug issue and attach the stacktrace please." % type)
return null
static func find_nodes_by_class(root: Node, cls: String, recursive: bool = false) -> Array[Node]:
if not recursive:
return _find_nodes_by_class_no_rec(root, cls)
return _find_nodes_by_class(root, cls)
static func _find_nodes_by_class_no_rec(parent: Node, cls: String) -> Array[Node]:
var result :Array[Node] = []
for ch in parent.get_children():
if ch.get_class() == cls:
result.append(ch)
return result
static func _find_nodes_by_class(root: Node, cls: String) -> Array[Node]:
var result :Array[Node] = []
var stack :Array[Node] = [root]
while stack:
var node :Node = stack.pop_back()
if node.get_class() == cls:
result.append(node)
for ch in node.get_children():
stack.push_back(ch)
return result

View file

@ -0,0 +1,57 @@
class_name GdUnit4Version
extends RefCounted
const VERSION_PATTERN = "[center][color=#9887c4]gd[/color][color=#7a57d6]Unit[/color][color=#9887c4]4[/color] [color=#9887c4]${version}[/color][/center]"
var _major :int
var _minor :int
var _patch :int
func _init(major :int,minor :int,patch :int):
_major = major
_minor = minor
_patch = patch
static func parse(value :String) -> GdUnit4Version:
var regex := RegEx.new()
regex.compile("[a-zA-Z:,-]+")
var cleaned := regex.sub(value, "", true)
var parts := cleaned.split(".")
var major := parts[0].to_int()
var minor := parts[1].to_int()
var patch := parts[2].to_int() if parts.size() > 2 else 0
return GdUnit4Version.new(major, minor, patch)
static func current() -> GdUnit4Version:
var config = ConfigFile.new()
config.load('addons/gdUnit4/plugin.cfg')
return parse(config.get_value('plugin', 'version'))
func equals(other :) -> bool:
return _major == other._major and _minor == other._minor and _patch == other._patch
func is_greater(other :) -> bool:
if _major > other._major:
return true
if _major == other._major and _minor > other._minor:
return true
return _major == other._major and _minor == other._minor and _patch > other._patch
static func init_version_label(label :Control) -> void:
var config = ConfigFile.new()
config.load('addons/gdUnit4/plugin.cfg')
var version = config.get_value('plugin', 'version')
if label is RichTextLabel:
label.text = VERSION_PATTERN.replace('${version}', version)
else:
label.text = "gdUnit4 " + version
func _to_string() -> String:
return "v%d.%d.%d" % [_major, _minor, _patch]

View file

@ -0,0 +1,122 @@
# A class doubler used to mock and spy checked implementations
class_name GdUnitClassDoubler
extends RefCounted
const DOUBLER_INSTANCE_ID_PREFIX := "gdunit_doubler_instance_id_"
const DOUBLER_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/core/GdUnitObjectInteractionsTemplate.gd")
const EXCLUDE_VIRTUAL_FUNCTIONS = [
# we have to exclude notifications because NOTIFICATION_PREDELETE is try
# to delete already freed spy/mock resources and will result in a conflict
"_notification",
# https://github.com/godotengine/godot/issues/67461
"get_name",
"get_path",
"duplicate",
]
# define functions to be exclude when spy or mock checked a scene
const EXLCUDE_SCENE_FUNCTIONS = [
# needs to exclude get/set script functions otherwise it endsup in recursive endless loop
"set_script",
"get_script",
# needs to exclude otherwise verify fails checked collection arguments checked calling to string
"_to_string",
]
const EXCLUDE_FUNCTIONS = ["new", "free", "get_instance_id", "get_tree"]
static func check_leaked_instances() -> void:
## we check that all registered spy/mock instances are removed from the engine meta data
for key in Engine.get_meta_list():
if key.begins_with(DOUBLER_INSTANCE_ID_PREFIX):
var instance = Engine.get_meta(key)
push_error("GdUnit internal error: an spy/mock instance '%s', class:'%s' is not removed from the engine and will lead in a leaked instance!" % [instance, instance.__SOURCE_CLASS])
# loads the doubler template
# class_info = { "class_name": <>, "class_path" : <>}
static func load_template(template :String, class_info :Dictionary, instance :Object) -> PackedStringArray:
# store instance id
var source_code = template\
.replace("${instance_id}", "%s%d" % [DOUBLER_INSTANCE_ID_PREFIX, abs(instance.get_instance_id())])\
.replace("${source_class}", class_info.get("class_name"))
var lines := GdScriptParser.to_unix_format(source_code).split("\n")
# replace template class_name with Doubled<class> name and extends form source class
lines.insert(0, "class_name Doubled%s" % class_info.get("class_name").replace(".", "_"))
lines.insert(1, extends_clazz(class_info))
# append Object interactions stuff
lines.append_array(GdScriptParser.to_unix_format(DOUBLER_TEMPLATE.source_code).split("\n"))
return lines
static func extends_clazz(class_info :Dictionary) -> String:
var clazz_name :String = class_info.get("class_name")
var clazz_path :PackedStringArray = class_info.get("class_path", [])
# is inner class?
if clazz_path.size() > 1:
return "extends %s" % clazz_name
if clazz_path.size() == 1 and clazz_path[0].ends_with(".gd"):
return "extends '%s'" % clazz_path[0]
return "extends %s" % clazz_name
# double all functions of given instance
static func double_functions(instance :Object, clazz_name :String, clazz_path :PackedStringArray, func_doubler: GdFunctionDoubler, exclude_functions :Array) -> PackedStringArray:
var doubled_source := PackedStringArray()
var parser := GdScriptParser.new()
var exclude_override_functions := EXCLUDE_VIRTUAL_FUNCTIONS + EXCLUDE_FUNCTIONS + exclude_functions
var functions := Array()
# double script functions
if not ClassDB.class_exists(clazz_name):
var result := parser.parse(clazz_name, clazz_path)
if result.is_error():
push_error(result.error_message())
return PackedStringArray()
var class_descriptor :GdClassDescriptor = result.value()
while class_descriptor != null:
for func_descriptor in class_descriptor.functions():
if instance != null and not instance.has_method(func_descriptor.name()):
#prints("no virtual func implemented",clazz_name, func_descriptor.name() )
continue
if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()):
continue
doubled_source += func_doubler.double(func_descriptor)
functions.append(func_descriptor.name())
class_descriptor = class_descriptor.parent()
# double regular class functions
var clazz_functions := GdObjects.extract_class_functions(clazz_name, clazz_path)
for method in clazz_functions:
var func_descriptor := GdFunctionDescriptor.extract_from(method)
# exclude private core functions
if func_descriptor.is_private():
continue
if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()):
continue
# GD-110: Hotfix do not double invalid engine functions
if is_invalid_method_descriptior(method):
#prints("'%s': invalid method descriptor found! %s" % [clazz_name, method])
continue
# do not double on not implemented virtual functions
if instance != null and not instance.has_method(func_descriptor.name()):
#prints("no virtual func implemented",clazz_name, func_descriptor.name() )
continue
functions.append(func_descriptor.name())
doubled_source.append_array(func_doubler.double(func_descriptor))
return doubled_source
# GD-110
static func is_invalid_method_descriptior(method :Dictionary) -> bool:
var return_info = method["return"]
var type :int = return_info["type"]
var usage :int = return_info["usage"]
var clazz_name :String = return_info["class_name"]
# is method returning a type int with a given 'class_name' we have an enum
# and the PROPERTY_USAGE_CLASS_IS_ENUM must be set
if type == TYPE_INT and not clazz_name.is_empty() and not (usage & PROPERTY_USAGE_CLASS_IS_ENUM):
return true
if clazz_name == "Variant.Type":
return true
return false

View file

@ -0,0 +1,211 @@
class_name GdUnitFileAccess
extends RefCounted
const GDUNIT_TEMP := "user://tmp"
static func current_dir() -> String:
return ProjectSettings.globalize_path("res://")
static func clear_tmp() -> void:
delete_directory(GDUNIT_TEMP)
# Creates a new file under
static func create_temp_file(relative_path :String, file_name :String, mode := FileAccess.WRITE) -> FileAccess:
var file_path := create_temp_dir(relative_path) + "/" + file_name
var file := FileAccess.open(file_path, mode)
if file == null:
push_error("Error creating temporary file at: %s, %s" % [file_path, error_string(FileAccess.get_open_error())])
return file
static func temp_dir() -> String:
if not DirAccess.dir_exists_absolute(GDUNIT_TEMP):
DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP)
return GDUNIT_TEMP
static func create_temp_dir(folder_name :String) -> String:
var new_folder := temp_dir() + "/" + folder_name
if not DirAccess.dir_exists_absolute(new_folder):
DirAccess.make_dir_recursive_absolute(new_folder)
return new_folder
static func copy_file(from_file :String, to_dir :String) -> GdUnitResult:
var dir := DirAccess.open(to_dir)
if dir != null:
var to_file := to_dir + "/" + from_file.get_file()
prints("Copy %s to %s" % [from_file, to_file])
var error := dir.copy(from_file, to_file)
if error != OK:
return GdUnitResult.error("Can't copy file form '%s' to '%s'. Error: '%s'" % [from_file, to_file, error_string(error)])
return GdUnitResult.success(to_file)
return GdUnitResult.error("Directory not found: " + to_dir)
static func copy_directory(from_dir :String, to_dir :String, recursive :bool = false) -> bool:
if not DirAccess.dir_exists_absolute(from_dir):
push_error("Source directory not found '%s'" % from_dir)
return false
# check if destination exists
if not DirAccess.dir_exists_absolute(to_dir):
# create it
var err := DirAccess.make_dir_recursive_absolute(to_dir)
if err != OK:
push_error("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)])
return false
var source_dir := DirAccess.open(from_dir)
var dest_dir := DirAccess.open(to_dir)
if source_dir != null:
source_dir.list_dir_begin()
var next := "."
while next != "":
next = source_dir.get_next()
if next == "" or next == "." or next == "..":
continue
var source := source_dir.get_current_dir() + "/" + next
var dest := dest_dir.get_current_dir() + "/" + next
if source_dir.current_is_dir():
if recursive:
copy_directory(source + "/", dest, recursive)
continue
var err := source_dir.copy(source, dest)
if err != OK:
push_error("Error checked copy file '%s' to '%s'" % [source, dest])
return false
return true
else:
push_error("Directory not found: " + from_dir)
return false
static func delete_directory(path :String, only_content := false) -> void:
var dir := DirAccess.open(path)
if dir != null:
dir.list_dir_begin()
var file_name := "."
while file_name != "":
file_name = dir.get_next()
if file_name.is_empty() or file_name == "." or file_name == "..":
continue
var next := path + "/" +file_name
if dir.current_is_dir():
delete_directory(next)
else:
# delete file
var err := dir.remove(next)
if err:
push_error("Delete %s failed: %s" % [next, error_string(err)])
if not only_content:
var err := dir.remove(path)
if err:
push_error("Delete %s failed: %s" % [path, error_string(err)])
static func delete_path_index_lower_equals_than(path :String, prefix :String, index :int) -> int:
var dir := DirAccess.open(path)
if dir == null:
return 0
var deleted := 0
dir.list_dir_begin()
var next := "."
while next != "":
next = dir.get_next()
if next.is_empty() or next == "." or next == "..":
continue
if next.begins_with(prefix):
var current_index := next.split("_")[1].to_int()
if current_index <= index:
deleted += 1
delete_directory(path + "/" + next)
return deleted
# scans given path for sub directories by given prefix and returns the highest index numer
# e.g. <prefix_%d>
static func find_last_path_index(path :String, prefix :String) -> int:
var dir := DirAccess.open(path)
if dir == null:
return 0
var last_iteration := 0
dir.list_dir_begin()
var next := "."
while next != "":
next = dir.get_next()
if next.is_empty() or next == "." or next == "..":
continue
if next.begins_with(prefix):
var iteration := next.split("_")[1].to_int()
if iteration > last_iteration:
last_iteration = iteration
return last_iteration
static func scan_dir(path :String) -> PackedStringArray:
var dir := DirAccess.open(path)
if dir == null or not dir.dir_exists(path):
return PackedStringArray()
var content := PackedStringArray()
dir.list_dir_begin()
var next := "."
while next != "":
next = dir.get_next()
if next.is_empty() or next == "." or next == "..":
continue
content.append(next)
return content
static func resource_as_array(resource_path :String) -> PackedStringArray:
var file := FileAccess.open(resource_path, FileAccess.READ)
if file == null:
push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())])
return PackedStringArray()
var file_content := PackedStringArray()
while not file.eof_reached():
file_content.append(file.get_line())
return file_content
static func resource_as_string(resource_path :String) -> String:
var file := FileAccess.open(resource_path, FileAccess.READ)
if file == null:
push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())])
return ""
return file.get_as_text(true)
static func make_qualified_path(path :String) -> String:
if not path.begins_with("res://"):
if path.begins_with("//"):
return path.replace("//", "res://")
if path.begins_with("/"):
return "res:/" + path
return path
static func extract_zip(zip_package :String, dest_path :String) -> GdUnitResult:
var zip: ZIPReader = ZIPReader.new()
var err := zip.open(zip_package)
if err != OK:
return GdUnitResult.error("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err])
var zip_entries: PackedStringArray = zip.get_files()
# Get base path and step over archive folder
var archive_path := zip_entries[0]
zip_entries.remove_at(0)
for zip_entry in zip_entries:
var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "")
if zip_entry.ends_with("/"):
DirAccess.make_dir_recursive_absolute(new_file_path)
continue
var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE)
file.store_buffer(zip.read_file(zip_entry))
zip.close()
return GdUnitResult.success(dest_path)

View file

@ -0,0 +1,42 @@
class_name GdUnitObjectInteractions
extends RefCounted
static func verify(interaction_object :Object, interactions_times):
if not _is_mock_or_spy(interaction_object, "__verify"):
return interaction_object
return interaction_object.__do_verify_interactions(interactions_times)
static func verify_no_interactions(interaction_object :Object) -> GdUnitAssert:
var __gd_assert = ResourceLoader.load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd", "GDScript", ResourceLoader.CACHE_MODE_REUSE).new("")
if not _is_mock_or_spy(interaction_object, "__verify"):
return __gd_assert.report_success()
var __summary :Dictionary = interaction_object.__verify_no_interactions()
if __summary.is_empty():
return __gd_assert.report_success()
return __gd_assert.report_error(GdAssertMessages.error_no_more_interactions(__summary))
static func verify_no_more_interactions(interaction_object :Object) -> GdUnitAssert:
var __gd_assert = ResourceLoader.load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd", "GDScript", ResourceLoader.CACHE_MODE_REUSE).new("")
if not _is_mock_or_spy(interaction_object, "__verify_no_more_interactions"):
return __gd_assert
var __summary :Dictionary = interaction_object.__verify_no_more_interactions()
if __summary.is_empty():
return __gd_assert
return __gd_assert.report_error(GdAssertMessages.error_no_more_interactions(__summary))
static func reset(interaction_object :Object) -> Object:
if not _is_mock_or_spy(interaction_object, "__reset"):
return interaction_object
interaction_object.__reset_interactions()
return interaction_object
static func _is_mock_or_spy(interaction_object :Object, mock_function_signature :String) -> bool:
if interaction_object is GDScript and not interaction_object.get_script().has_script_method(mock_function_signature):
push_error("Error: You try to use a non mock or spy!")
return false
return true

View file

@ -0,0 +1,91 @@
const GdUnitAssertImpl := preload("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd")
var __expected_interactions :int = -1
var __saved_interactions := Dictionary()
var __verified_interactions := Array()
func __save_function_interaction(function_args :Array[Variant]) -> void:
var __matcher := GdUnitArgumentMatchers.to_matcher(function_args, true)
for __index in __saved_interactions.keys().size():
var __key :Variant = __saved_interactions.keys()[__index]
if __matcher.is_match(__key):
__saved_interactions[__key] += 1
return
__saved_interactions[function_args] = 1
func __is_verify_interactions() -> bool:
return __expected_interactions != -1
func __do_verify_interactions(interactions_times :int = 1) -> Object:
__expected_interactions = interactions_times
return self
func __verify_interactions(function_args :Array[Variant]) -> void:
var __summary := Dictionary()
var __total_interactions := 0
var __matcher := GdUnitArgumentMatchers.to_matcher(function_args, true)
for __index in __saved_interactions.keys().size():
var __key :Variant = __saved_interactions.keys()[__index]
if __matcher.is_match(__key):
var __interactions :int = __saved_interactions.get(__key, 0)
__total_interactions += __interactions
__summary[__key] = __interactions
# add as verified
__verified_interactions.append(__key)
var __gd_assert := GdUnitAssertImpl.new("")
if __total_interactions != __expected_interactions:
var __expected_summary := {function_args : __expected_interactions}
var __error_message :String
# if no __interactions macht collect not verified __interactions for failure report
if __summary.is_empty():
var __current_summary := __verify_no_more_interactions()
__error_message = GdAssertMessages.error_validate_interactions(__current_summary, __expected_summary)
else:
__error_message = GdAssertMessages.error_validate_interactions(__summary, __expected_summary)
__gd_assert.report_error(__error_message)
else:
__gd_assert.report_success()
__expected_interactions = -1
func __verify_no_interactions() -> Dictionary:
var __summary := Dictionary()
if not __saved_interactions.is_empty():
for __index in __saved_interactions.keys().size():
var func_call :Variant = __saved_interactions.keys()[__index]
__summary[func_call] = __saved_interactions[func_call]
return __summary
func __verify_no_more_interactions() -> Dictionary:
var __summary := Dictionary()
var called_functions :Array[Variant] = __saved_interactions.keys()
if called_functions != __verified_interactions:
# collect the not verified functions
var called_but_not_verified := called_functions.duplicate()
for __index in __verified_interactions.size():
called_but_not_verified.erase(__verified_interactions[__index])
for __index in called_but_not_verified.size():
var not_verified :Variant = called_but_not_verified[__index]
__summary[not_verified] = __saved_interactions[not_verified]
return __summary
func __reset_interactions() -> void:
__saved_interactions.clear()
func __filter_vargs(arg_values :Array[Variant]) -> Array[Variant]:
var filtered :Array[Variant] = []
for __index in arg_values.size():
var arg :Variant = arg_values[__index]
if typeof(arg) == TYPE_STRING and arg == GdObjects.TYPE_VARARG_PLACEHOLDER_VALUE:
continue
filtered.append(arg)
return filtered

View file

@ -0,0 +1,72 @@
class_name GdUnitProperty
extends RefCounted
var _name :String
var _help :String
var _type :int
var _value :Variant
var _value_set :PackedStringArray
var _default :Variant
func _init(p_name :String, p_type :int, p_value :Variant, p_default_value :Variant, p_help :="", p_value_set := PackedStringArray()) -> void:
_name = p_name
_type = p_type
_value = p_value
_value_set = p_value_set
_default = p_default_value
_help = p_help
func name() -> String:
return _name
func type() -> int:
return _type
func value() -> Variant:
return _value
func value_set() -> PackedStringArray:
return _value_set
func is_selectable_value() -> bool:
return not _value_set.is_empty()
func set_value(p_value :Variant) -> void:
match _type:
TYPE_STRING:
_value = str(p_value)
TYPE_BOOL:
_value = bool(p_value)
TYPE_INT:
_value = int(p_value)
TYPE_FLOAT:
_value = float(p_value)
_:
_value = p_value
func default() -> Variant:
return _default
func category() -> String:
var elements := _name.split("/")
if elements.size() > 3:
return elements[2]
return ""
func help() -> String:
return _help
func _to_string() -> String:
return "%-64s %-10s %-10s (%s) help:%s set:%s" % [name(), type(), value(), default(), help(), _value_set]

View file

@ -0,0 +1,104 @@
class_name GdUnitResult
extends RefCounted
enum {
SUCCESS,
WARN,
ERROR,
EMPTY
}
var _state :Variant
var _warn_message := ""
var _error_message := ""
var _value :Variant = null
static func empty() -> GdUnitResult:
var result := GdUnitResult.new()
result._state = EMPTY
return result
static func success(p_value :Variant) -> GdUnitResult:
assert(p_value != null, "The value must not be NULL")
var result := GdUnitResult.new()
result._value = p_value
result._state = SUCCESS
return result
static func warn(p_warn_message :String, p_value :Variant = null) -> GdUnitResult:
assert(not p_warn_message.is_empty()) #,"The message must not be empty")
var result := GdUnitResult.new()
result._value = p_value
result._warn_message = p_warn_message
result._state = WARN
return result
static func error(p_error_message :String) -> GdUnitResult:
assert(not p_error_message.is_empty(), "The message must not be empty")
var result := GdUnitResult.new()
result._value = null
result._error_message = p_error_message
result._state = ERROR
return result
func is_success() -> bool:
return _state == SUCCESS
func is_warn() -> bool:
return _state == WARN
func is_error() -> bool:
return _state == ERROR
func is_empty() -> bool:
return _state == EMPTY
func value() -> Variant:
return _value
func or_else(p_value :Variant) -> Variant:
if not is_success():
return p_value
return value()
func error_message() -> String:
return _error_message
func warn_message() -> String:
return _warn_message
func _to_string() -> String:
return str(GdUnitResult.serialize(self))
static func serialize(result :GdUnitResult) -> Dictionary:
if result == null:
push_error("Can't serialize a Null object from type GdUnitResult")
return {
"state" : result._state,
"value" : var_to_str(result._value),
"warn_msg" : result._warn_message,
"err_msg" : result._error_message
}
static func deserialize(config :Dictionary) -> GdUnitResult:
var result := GdUnitResult.new()
result._value = str_to_var(config.get("value", ""))
result._warn_message = config.get("warn_msg", null)
result._error_message = config.get("err_msg", null)
result._state = config.get("state")
return result

View file

@ -0,0 +1,168 @@
extends Node
signal sync_rpc_id_result_received
@onready var _client :GdUnitTcpClient = $GdUnitTcpClient
@onready var _executor :GdUnitTestSuiteExecutor = GdUnitTestSuiteExecutor.new()
enum {
INIT,
RUN,
STOP,
EXIT
}
const GDUNIT_RUNNER = "GdUnitRunner"
var _config := GdUnitRunnerConfig.new()
var _test_suites_to_process :Array
var _state :int = INIT
var _cs_executor :RefCounted
func _init() -> void:
# minimize scene window checked debug mode
if OS.get_cmdline_args().size() == 1:
DisplayServer.window_set_title("GdUnit4 Runner (Debug Mode)")
else:
DisplayServer.window_set_title("GdUnit4 Runner (Release Mode)")
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
# store current runner instance to engine meta data to can be access in as a singleton
Engine.set_meta(GDUNIT_RUNNER, self)
_cs_executor = GdUnit4CSharpApiLoader.create_executor(self)
func _ready() -> void:
var config_result := _config.load_config()
if config_result.is_error():
push_error(config_result.error_message())
_state = EXIT
return
_client.connect("connection_failed", _on_connection_failed)
GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
var result := _client.start("127.0.0.1", _config.server_port())
if result.is_error():
push_error(result.error_message())
return
_state = INIT
func _on_connection_failed(message :String) -> void:
prints("_on_connection_failed", message, _test_suites_to_process)
_state = STOP
func _notification(what :int) -> void:
#prints("GdUnitRunner", self, GdObjects.notification_as_string(what))
if what == NOTIFICATION_PREDELETE:
Engine.remove_meta(GDUNIT_RUNNER)
func _process(_delta :float) -> void:
match _state:
INIT:
# wait until client is connected to the GdUnitServer
if _client.is_client_connected():
var time := LocalTime.now()
prints("Scan for test suites.")
_test_suites_to_process = load_test_suits()
prints("Scanning of %d test suites took" % _test_suites_to_process.size(), time.elapsed_since())
gdUnitInit()
_state = RUN
RUN:
# all test suites executed
if _test_suites_to_process.is_empty():
_state = STOP
else:
# process next test suite
set_process(false)
var test_suite :Node = _test_suites_to_process.pop_front()
if _cs_executor != null and _cs_executor.IsExecutable(test_suite):
_cs_executor.Execute(test_suite)
await _cs_executor.ExecutionCompleted
else:
await _executor.execute(test_suite)
set_process(true)
STOP:
_state = EXIT
# give the engine small amount time to finish the rpc
_on_gdunit_event(GdUnitStop.new())
await get_tree().create_timer(0.1).timeout
await get_tree().process_frame
get_tree().quit(0)
func load_test_suits() -> Array:
var to_execute := _config.to_execute()
if to_execute.is_empty():
prints("No tests selected to execute!")
_state = EXIT
return []
# scan for the requested test suites
var test_suites := Array()
var _scanner := GdUnitTestSuiteScanner.new()
for resource_path in to_execute.keys():
var selected_tests :PackedStringArray = to_execute.get(resource_path)
var scaned_suites := _scanner.scan(resource_path)
_filter_test_case(scaned_suites, selected_tests)
test_suites += scaned_suites
return test_suites
func gdUnitInit() -> void:
#enable_manuall_polling()
send_message("Scaned %d test suites" % _test_suites_to_process.size())
var total_count := _collect_test_case_count(_test_suites_to_process)
_on_gdunit_event(GdUnitInit.new(_test_suites_to_process.size(), total_count))
for test_suite in _test_suites_to_process:
send_test_suite(test_suite)
func _filter_test_case(test_suites :Array, included_tests :PackedStringArray) -> void:
if included_tests.is_empty():
return
for test_suite in test_suites:
for test_case in test_suite.get_children():
_do_filter_test_case(test_suite, test_case, included_tests)
func _do_filter_test_case(test_suite :Node, test_case :Node, included_tests :PackedStringArray) -> void:
for included_test in included_tests:
var test_meta :PackedStringArray = included_test.split(":")
var test_name := test_meta[0]
if test_case.get_name() == test_name:
# we have a paremeterized test selection
if test_meta.size() > 1:
var test_param_index := test_meta[1]
test_case.set_test_parameter_index(test_param_index.to_int())
return
# the test is filtered out
test_suite.remove_child(test_case)
test_case.free()
func _collect_test_case_count(testSuites :Array) -> int:
var total :int = 0
for test_suite in testSuites:
total += test_suite.get_child_count()
return total
# RPC send functions
func send_message(message :String) -> void:
_client.rpc_send(RPCMessage.of(message))
func send_test_suite(test_suite :Node) -> void:
_client.rpc_send(RPCGdUnitTestSuite.of(test_suite))
func _on_gdunit_event(event :GdUnitEvent) -> void:
_client.rpc_send(RPCGdUnitEvent.of(event))
# Event bridge from C# GdUnit4.ITestEventListener.cs
func PublishEvent(data :Dictionary) -> void:
var event := GdUnitEvent.new().deserialize(data)
_client.rpc_send(RPCGdUnitEvent.of(event))

View file

@ -0,0 +1,10 @@
[gd_scene load_steps=3 format=3 uid="uid://belidlfknh74r"]
[ext_resource type="Script" path="res://addons/gdUnit4/src/core/GdUnitRunner.gd" id="1"]
[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpClient.gd" id="2"]
[node name="Control" type="Node"]
script = ExtResource("1")
[node name="GdUnitTcpClient" type="Node" parent="."]
script = ExtResource("2")

View file

@ -0,0 +1,153 @@
class_name GdUnitRunnerConfig
extends Resource
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const CONFIG_VERSION = "1.0"
const VERSION = "version"
const INCLUDED = "included"
const SKIPPED = "skipped"
const SERVER_PORT = "server_port"
const EXIT_FAIL_FAST ="exit_on_first_fail"
const CONFIG_FILE = "res://addons/gdUnit4/GdUnitRunner.cfg"
var _config := {
VERSION : CONFIG_VERSION,
# a set of directories or testsuite paths as key and a optional set of testcases as values
INCLUDED : Dictionary(),
# a set of skipped directories or testsuite paths
SKIPPED : Dictionary(),
# the port of running test server for this session
SERVER_PORT : -1
}
func clear() -> GdUnitRunnerConfig:
_config[INCLUDED] = Dictionary()
_config[SKIPPED] = Dictionary()
return self
func set_server_port(port :int) -> GdUnitRunnerConfig:
_config[SERVER_PORT] = port
return self
func server_port() -> int:
return _config.get(SERVER_PORT, -1)
func self_test() -> GdUnitRunnerConfig:
add_test_suite("res://addons/gdUnit4/test/")
add_test_suite("res://addons/gdUnit4/mono/test/")
return self
func add_test_suite(p_resource_path :String) -> GdUnitRunnerConfig:
var to_execute_ := to_execute()
to_execute_[p_resource_path] = to_execute_.get(p_resource_path, PackedStringArray())
return self
func add_test_suites(resource_paths :PackedStringArray) -> GdUnitRunnerConfig:
for resource_path_ in resource_paths:
add_test_suite(resource_path_)
return self
func add_test_case(p_resource_path :String, test_name :StringName, test_param_index :int = -1) -> GdUnitRunnerConfig:
var to_execute_ := to_execute()
var test_cases :PackedStringArray = to_execute_.get(p_resource_path, PackedStringArray())
if test_param_index != -1:
test_cases.append("%s:%d" % [test_name, test_param_index])
else:
test_cases.append(test_name)
to_execute_[p_resource_path] = test_cases
return self
# supports full path or suite name with optional test case name
# <test_suite_name|path>[:<test_case_name>]
# '/path/path', res://path/path', 'res://path/path/testsuite.gd' or 'testsuite'
# 'res://path/path/testsuite.gd:test_case' or 'testsuite:test_case'
func skip_test_suite(value :StringName) -> GdUnitRunnerConfig:
var parts :Array = GdUnitFileAccess.make_qualified_path(value).rsplit(":")
if parts[0] == "res":
parts.pop_front()
parts[0] = GdUnitFileAccess.make_qualified_path(parts[0])
match parts.size():
1: skipped()[parts[0]] = PackedStringArray()
2: skip_test_case(parts[0], parts[1])
return self
func skip_test_suites(resource_paths :PackedStringArray) -> GdUnitRunnerConfig:
for resource_path_ in resource_paths:
skip_test_suite(resource_path_)
return self
func skip_test_case(p_resource_path :String, test_name :StringName) -> GdUnitRunnerConfig:
var to_ignore := skipped()
var test_cases :PackedStringArray = to_ignore.get(p_resource_path, PackedStringArray())
test_cases.append(test_name)
to_ignore[p_resource_path] = test_cases
return self
func to_execute() -> Dictionary:
return _config.get(INCLUDED, {"res://" : PackedStringArray()})
func skipped() -> Dictionary:
return _config.get(SKIPPED, PackedStringArray())
func save_config(path :String = CONFIG_FILE) -> GdUnitResult:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
var error = FileAccess.get_open_error()
return GdUnitResult.error("Can't write test runner configuration '%s'! %s" % [path, error_string(error)])
_config[VERSION] = CONFIG_VERSION
file.store_string(JSON.stringify(_config))
return GdUnitResult.success(path)
func load_config(path :String = CONFIG_FILE) -> GdUnitResult:
if not FileAccess.file_exists(path):
return GdUnitResult.error("Can't find test runner configuration '%s'! Please select a test to run." % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var error = FileAccess.get_open_error()
return GdUnitResult.error("Can't load test runner configuration '%s'! ERROR: %s." % [path, error_string(error)])
var content := file.get_as_text()
if not content.is_empty() and content[0] == '{':
# Parse as json
var test_json_conv := JSON.new()
var error := test_json_conv.parse(content)
if error != OK:
return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path)
_config = test_json_conv.get_data() as Dictionary
if not _config.has(VERSION):
return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path)
fix_value_types()
return GdUnitResult.success(path)
func fix_value_types():
# fix float value to int json stores all numbers as float
var server_port_ :int = _config.get(SERVER_PORT, -1)
_config[SERVER_PORT] = server_port_
convert_Array_to_PackedStringArray(_config[INCLUDED])
convert_Array_to_PackedStringArray(_config[SKIPPED])
func convert_Array_to_PackedStringArray(data :Dictionary):
for key in data.keys():
var values :Array = data[key]
data[key] = PackedStringArray(values)
func _to_string() -> String:
return str(_config)

View file

@ -0,0 +1,418 @@
# This class provides a runner for scense to simulate interactions like keyboard or mouse
class_name GdUnitSceneRunnerImpl
extends GdUnitSceneRunner
var GdUnitFuncAssertImpl := ResourceLoader.load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd", "GDScript", ResourceLoader.CACHE_MODE_REUSE)
# mapping of mouse buttons and his masks
const MAP_MOUSE_BUTTON_MASKS := {
MOUSE_BUTTON_LEFT : MOUSE_BUTTON_MASK_LEFT,
MOUSE_BUTTON_RIGHT : MOUSE_BUTTON_MASK_RIGHT,
MOUSE_BUTTON_MIDDLE : MOUSE_BUTTON_MASK_MIDDLE,
# https://github.com/godotengine/godot/issues/73632
MOUSE_BUTTON_WHEEL_UP : 1 << (MOUSE_BUTTON_WHEEL_UP - 1),
MOUSE_BUTTON_WHEEL_DOWN : 1 << (MOUSE_BUTTON_WHEEL_DOWN - 1),
MOUSE_BUTTON_XBUTTON1 : MOUSE_BUTTON_MASK_MB_XBUTTON1,
MOUSE_BUTTON_XBUTTON2 : MOUSE_BUTTON_MASK_MB_XBUTTON2,
}
var _is_disposed := false
var _current_scene :Node = null
var _awaiter :GdUnitAwaiter = GdUnitAwaiter.new()
var _verbose :bool
var _simulate_start_time :LocalTime
var _last_input_event :InputEvent = null
var _mouse_button_on_press := []
var _key_on_press := []
var _curent_mouse_position :Vector2
# time factor settings
var _time_factor := 1.0
var _saved_iterations_per_second :float
var _scene_auto_free := false
func _init(p_scene, p_verbose :bool, p_hide_push_errors = false):
_verbose = p_verbose
_saved_iterations_per_second = Engine.get_physics_ticks_per_second()
set_time_factor(1)
# handle scene loading by resource path
if typeof(p_scene) == TYPE_STRING:
if !FileAccess.file_exists(p_scene):
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: Can't load scene by given resource path: '%s'. The resource not exists." % p_scene)
return
if !str(p_scene).ends_with("tscn"):
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: The given resource: '%s'. is not a scene." % p_scene)
return
_current_scene = load(p_scene).instantiate()
_scene_auto_free = true
else:
# verify we have a node instance
if not p_scene is Node:
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: The given instance '%s' is not a Node." % p_scene)
return
_current_scene = p_scene
if _current_scene == null:
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: Scene must be not null!")
return
_scene_tree().root.add_child(_current_scene)
# do finally reset all open input events when the scene is removed
_scene_tree().root.child_exiting_tree.connect(func f(child):
if child == _current_scene:
_reset_input_to_default()
)
_simulate_start_time = LocalTime.now()
# we need to set inital a valid window otherwise the warp_mouse() is not handled
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
# set inital mouse pos to 0,0
var max_iteration_to_wait = 0
while get_global_mouse_position() != Vector2.ZERO and max_iteration_to_wait < 100:
Input.warp_mouse(Vector2.ZERO)
max_iteration_to_wait += 1
func _notification(what):
if what == NOTIFICATION_PREDELETE and is_instance_valid(self):
# reset time factor to normal
__deactivate_time_factor()
if is_instance_valid(_current_scene):
_scene_tree().root.remove_child(_current_scene)
# do only free scenes instanciated by this runner
if _scene_auto_free:
_current_scene.free()
_is_disposed = true
_current_scene = null
# we hide the scene/main window after runner is finished
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
func _scene_tree() -> SceneTree:
return Engine.get_main_loop() as SceneTree
func simulate_key_pressed(key_code :int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
simulate_key_press(key_code, shift_pressed, ctrl_pressed)
simulate_key_release(key_code, shift_pressed, ctrl_pressed)
return self
func simulate_key_press(key_code :int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
__print_current_focus()
var event = InputEventKey.new()
event.pressed = true
event.keycode = key_code
event.physical_keycode = key_code
event.alt_pressed = key_code == KEY_ALT
event.shift_pressed = shift_pressed or key_code == KEY_SHIFT
event.ctrl_pressed = ctrl_pressed or key_code == KEY_CTRL
_apply_input_modifiers(event)
_key_on_press.append(key_code)
return _handle_input_event(event)
func simulate_key_release(key_code :int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
__print_current_focus()
var event = InputEventKey.new()
event.pressed = false
event.keycode = key_code
event.physical_keycode = key_code
event.alt_pressed = key_code == KEY_ALT
event.shift_pressed = shift_pressed or key_code == KEY_SHIFT
event.ctrl_pressed = ctrl_pressed or key_code == KEY_CTRL
_apply_input_modifiers(event)
_key_on_press.erase(key_code)
return _handle_input_event(event)
func set_mouse_pos(pos :Vector2) -> GdUnitSceneRunner:
var event := InputEventMouseMotion.new()
event.position = pos
event.global_position = get_global_mouse_position()
_apply_input_modifiers(event)
return _handle_input_event(event)
func get_mouse_position() -> Vector2:
if _last_input_event is InputEventMouse:
return _last_input_event.position
var current_scene := scene()
if current_scene != null:
return current_scene.get_viewport().get_mouse_position()
return Vector2.ZERO
func get_global_mouse_position() -> Vector2:
return Engine.get_main_loop().root.get_mouse_position()
func simulate_mouse_move(pos :Vector2) -> GdUnitSceneRunner:
var event := InputEventMouseMotion.new()
event.position = pos
event.relative = pos - get_mouse_position()
event.global_position = get_global_mouse_position()
_apply_input_mouse_mask(event)
_apply_input_modifiers(event)
return _handle_input_event(event)
func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
var tween := _scene_tree().create_tween()
_curent_mouse_position = get_mouse_position()
var final_position := _curent_mouse_position + relative
tween.tween_property(self, "_curent_mouse_position", final_position, time).set_trans(trans_type)
tween.play()
while not get_mouse_position().is_equal_approx(final_position):
simulate_mouse_move(_curent_mouse_position)
await _scene_tree().process_frame
return self
func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
var tween := _scene_tree().create_tween()
_curent_mouse_position = get_mouse_position()
tween.tween_property(self, "_curent_mouse_position", position, time).set_trans(trans_type)
tween.play()
while not get_mouse_position().is_equal_approx(position):
simulate_mouse_move(_curent_mouse_position)
await _scene_tree().process_frame
return self
func simulate_mouse_button_pressed(buttonIndex :MouseButton, double_click := false) -> GdUnitSceneRunner:
simulate_mouse_button_press(buttonIndex, double_click)
simulate_mouse_button_release(buttonIndex)
return self
func simulate_mouse_button_press(buttonIndex :MouseButton, double_click := false) -> GdUnitSceneRunner:
var event := InputEventMouseButton.new()
event.button_index = buttonIndex
event.pressed = true
event.double_click = double_click
_apply_input_mouse_position(event)
_apply_input_mouse_mask(event)
_apply_input_modifiers(event)
_mouse_button_on_press.append(buttonIndex)
return _handle_input_event(event)
func simulate_mouse_button_release(buttonIndex :MouseButton) -> GdUnitSceneRunner:
var event := InputEventMouseButton.new()
event.button_index = buttonIndex
event.pressed = false
_apply_input_mouse_position(event)
_apply_input_mouse_mask(event)
_apply_input_modifiers(event)
_mouse_button_on_press.erase(buttonIndex)
return _handle_input_event(event)
func set_time_factor(time_factor := 1.0) -> GdUnitSceneRunner:
_time_factor = min(9.0, time_factor)
__activate_time_factor()
__print("set time factor: %f" % _time_factor)
__print("set physics physics_ticks_per_second: %d" % (_saved_iterations_per_second*_time_factor))
return self
func simulate_frames(frames: int, delta_milli :int = -1) -> GdUnitSceneRunner:
var time_shift_frames :int = max(1, frames / _time_factor)
for frame in time_shift_frames:
if delta_milli == -1:
await _scene_tree().process_frame
else:
await _scene_tree().create_timer(delta_milli * 0.001).timeout
return self
func simulate_until_signal(signal_name :String, arg0=NO_ARG, arg1=NO_ARG, arg2=NO_ARG, arg3=NO_ARG, arg4=NO_ARG, arg5=NO_ARG, arg6=NO_ARG, arg7=NO_ARG, arg8=NO_ARG, arg9=NO_ARG) -> GdUnitSceneRunner:
var args = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG)
await _awaiter.await_signal_idle_frames(scene(), signal_name, args, 10000)
return self
func simulate_until_object_signal(source :Object, signal_name :String, arg0=NO_ARG, arg1=NO_ARG, arg2=NO_ARG, arg3=NO_ARG, arg4=NO_ARG, arg5=NO_ARG, arg6=NO_ARG, arg7=NO_ARG, arg8=NO_ARG, arg9=NO_ARG) -> GdUnitSceneRunner:
var args = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG)
await _awaiter.await_signal_idle_frames(source, signal_name, args, 10000)
return self
func await_func(func_name :String, args := []) -> GdUnitFuncAssert:
return GdUnitFuncAssertImpl.new(scene(), func_name, args)
func await_func_on(instance :Object, func_name :String, args := []) -> GdUnitFuncAssert:
return GdUnitFuncAssertImpl.new(instance, func_name, args)
func await_signal(signal_name :String, args := [], timeout := 2000 ):
await _awaiter.await_signal_on(scene(), signal_name, args, timeout)
func await_signal_on(source :Object, signal_name :String, args := [], timeout := 2000 ):
await _awaiter.await_signal_on(source, signal_name, args, timeout)
# maximizes the window to bring the scene visible
func maximize_view() -> GdUnitSceneRunner:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_move_to_foreground()
return self
func _property_exists(name :String) -> bool:
return scene().get_property_list().any(func(properties :Dictionary) : return properties["name"] == name)
func get_property(name :String) -> Variant:
if not _property_exists(name):
return "The property '%s' not exist checked loaded scene." % name
return scene().get(name)
func set_property(name :String, value :Variant) -> bool:
if not _property_exists(name):
push_error("The property named '%s' cannot be set, it does not exist!" % name)
return false;
scene().set(name, value)
return true
func invoke(name :String, arg0=NO_ARG, arg1=NO_ARG, arg2=NO_ARG, arg3=NO_ARG, arg4=NO_ARG, arg5=NO_ARG, arg6=NO_ARG, arg7=NO_ARG, arg8=NO_ARG, arg9=NO_ARG):
var args = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG)
if scene().has_method(name):
return scene().callv(name, args)
return "The method '%s' not exist checked loaded scene." % name
func find_child(name :String, recursive :bool = true, owned :bool = false) -> Node:
return scene().find_child(name, recursive, owned)
func _scene_name() -> String:
var scene_script :GDScript = scene().get_script()
var scene_name :String = scene().get_name()
if not scene_script:
return scene_name
if not scene_name.begins_with("@"):
return scene_name
return scene_script.resource_name.get_basename()
func __activate_time_factor() -> void:
Engine.set_time_scale(_time_factor)
Engine.set_physics_ticks_per_second((_saved_iterations_per_second * _time_factor) as int)
func __deactivate_time_factor() -> void:
Engine.set_time_scale(1)
Engine.set_physics_ticks_per_second(_saved_iterations_per_second as int)
# copy over current active modifiers
func _apply_input_modifiers(event :InputEvent) -> void:
if _last_input_event is InputEventWithModifiers and event is InputEventWithModifiers:
event.meta_pressed = event.meta_pressed or _last_input_event.meta_pressed
event.alt_pressed = event.alt_pressed or _last_input_event.alt_pressed
event.shift_pressed = event.shift_pressed or _last_input_event.shift_pressed
event.ctrl_pressed = event.ctrl_pressed or _last_input_event.ctrl_pressed
# this line results into reset the control_pressed state!!!
#event.command_or_control_autoremap = event.command_or_control_autoremap or _last_input_event.command_or_control_autoremap
# copy over current active mouse mask and combine with curren mask
func _apply_input_mouse_mask(event :InputEvent) -> void:
# first apply last mask
if _last_input_event is InputEventMouse and event is InputEventMouse:
event.button_mask |= _last_input_event.button_mask
if event is InputEventMouseButton:
var button_mask = MAP_MOUSE_BUTTON_MASKS.get(event.get_button_index(), 0)
if event.is_pressed():
event.button_mask |= button_mask
else:
event.button_mask ^= button_mask
# copy over last mouse position if need
func _apply_input_mouse_position(event :InputEvent) -> void:
if _last_input_event is InputEventMouse and event is InputEventMouseButton:
event.position = _last_input_event.position
## just for testing maunally event to action handling
func _handle_actions(event :InputEvent) -> bool:
var is_action_match := false
for action in InputMap.get_actions():
if InputMap.event_is_action(event, action, true):
is_action_match = true
prints(action, event, event.is_ctrl_pressed())
if event.is_pressed():
Input.action_press(action, InputMap.action_get_deadzone(action))
else:
Input.action_release(action)
return is_action_match
# for handling read https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html?highlight=inputevent#how-does-it-work
func _handle_input_event(event :InputEvent):
if event is InputEventMouse:
Input.warp_mouse(event.position)
Input.parse_input_event(event)
Input.flush_buffered_events()
var current_scene := scene()
if is_instance_valid(current_scene):
__print(" process event %s (%s) <- %s" % [current_scene, _scene_name(), event.as_text()])
if(current_scene.has_method("_gui_input")):
current_scene._gui_input(event)
if(current_scene.has_method("_unhandled_input")):
current_scene._unhandled_input(event)
current_scene.get_viewport().set_input_as_handled()
# save last input event needs to be merged with next InputEventMouseButton
_last_input_event = event
return self
func _reset_input_to_default() -> void:
# reset all mouse button to inital state if need
for m_button in _mouse_button_on_press.duplicate():
if Input.is_mouse_button_pressed(m_button):
simulate_mouse_button_release(m_button)
_mouse_button_on_press.clear()
for key_scancode in _key_on_press.duplicate():
if Input.is_key_pressed(key_scancode):
simulate_key_release(key_scancode)
_key_on_press.clear()
Input.flush_buffered_events()
_last_input_event = null
func __print(message :String) -> void:
if _verbose:
prints(message)
func __print_current_focus() -> void:
if not _verbose:
return
var focused_node = scene().get_viewport().gui_get_focus_owner()
if focused_node:
prints(" focus checked %s" % focused_node)
else:
prints(" no focus set")
func scene() -> Node:
if is_instance_valid(_current_scene):
return _current_scene
if not _is_disposed:
push_error("The current scene instance is not valid anymore! check your test is valid. e.g. check for missing awaits.")
return null

View file

@ -0,0 +1,16 @@
class_name GdUnitScriptType
extends RefCounted
const UNKNOWN := ""
const CS := "cs"
const GD := "gd"
static func type_of(script :Script) -> String:
if script == null:
return UNKNOWN
if GdObjects.is_gd_script(script):
return GD
if GdObjects.is_cs_script(script):
return CS
return UNKNOWN

View file

@ -0,0 +1,336 @@
@tool
class_name GdUnitSettings
extends RefCounted
const MAIN_CATEGORY = "gdunit4"
# Common Settings
const COMMON_SETTINGS = MAIN_CATEGORY + "/settings"
const GROUP_COMMON = COMMON_SETTINGS + "/common"
const UPDATE_NOTIFICATION_ENABLED = GROUP_COMMON + "/update_notification_enabled"
const SERVER_TIMEOUT = GROUP_COMMON + "/server_connection_timeout_minutes"
const GROUP_TEST = COMMON_SETTINGS + "/test"
const TEST_TIMEOUT = GROUP_TEST + "/test_timeout_seconds"
const TEST_LOOKUP_FOLDER = GROUP_TEST + "/test_lookup_folder"
const TEST_SITE_NAMING_CONVENTION = GROUP_TEST + "/test_suite_naming_convention"
# Report Setiings
const REPORT_SETTINGS = MAIN_CATEGORY + "/report"
const GROUP_GODOT = REPORT_SETTINGS + "/godot"
const REPORT_PUSH_ERRORS = GROUP_GODOT + "/push_error"
const REPORT_SCRIPT_ERRORS = GROUP_GODOT + "/script_error"
const REPORT_ORPHANS = REPORT_SETTINGS + "/verbose_orphans"
const GROUP_ASSERT = REPORT_SETTINGS + "/assert"
const REPORT_ASSERT_WARNINGS = GROUP_ASSERT + "/verbose_warnings"
const REPORT_ASSERT_ERRORS = GROUP_ASSERT + "/verbose_errors"
const REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE = GROUP_ASSERT + "/strict_number_type_compare"
# Godot debug stdout/logging settings
const CATEGORY_LOGGING := "debug/file_logging/"
const STDOUT_ENABLE_TO_FILE = CATEGORY_LOGGING + "enable_file_logging"
const STDOUT_WITE_TO_FILE = CATEGORY_LOGGING + "log_path"
# GdUnit Templates
const TEMPLATES = MAIN_CATEGORY + "/templates"
const TEMPLATES_TS = TEMPLATES + "/testsuite"
const TEMPLATE_TS_GD = TEMPLATES_TS + "/GDScript"
const TEMPLATE_TS_CS = TEMPLATES_TS + "/CSharpScript"
# UI Setiings
const UI_SETTINGS = MAIN_CATEGORY + "/ui"
const GROUP_UI_INSPECTOR = UI_SETTINGS + "/inspector"
const INSPECTOR_NODE_COLLAPSE = GROUP_UI_INSPECTOR + "/node_collapse"
# Shortcut Setiings
const SHORTCUT_SETTINGS = MAIN_CATEGORY + "/Shortcuts"
const GROUP_SHORTCUT_INSPECTOR = SHORTCUT_SETTINGS + "/inspector"
const SHORTCUT_INSPECTOR_RERUN_TEST = GROUP_SHORTCUT_INSPECTOR + "/rerun_test"
const SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_debug"
const SHORTCUT_INSPECTOR_RUN_TEST_OVERALL = GROUP_SHORTCUT_INSPECTOR + "/run_test_overall"
const SHORTCUT_INSPECTOR_RUN_TEST_STOP = GROUP_SHORTCUT_INSPECTOR + "/run_test_stop"
const GROUP_SHORTCUT_EDITOR = SHORTCUT_SETTINGS + "/editor"
const SHORTCUT_EDITOR_RUN_TEST = GROUP_SHORTCUT_EDITOR + "/run_test"
const SHORTCUT_EDITOR_RUN_TEST_DEBUG = GROUP_SHORTCUT_EDITOR + "/run_test_debug"
const SHORTCUT_EDITOR_CREATE_TEST = GROUP_SHORTCUT_EDITOR + "/create_test"
const GROUP_SHORTCUT_FILESYSTEM = SHORTCUT_SETTINGS + "/filesystem"
const SHORTCUT_FILESYSTEM_RUN_TEST = GROUP_SHORTCUT_FILESYSTEM + "/run_test"
const SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG = GROUP_SHORTCUT_FILESYSTEM + "/run_test_debug"
# Toolbar Setiings
const GROUP_UI_TOOLBAR = UI_SETTINGS + "/toolbar"
const INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL = GROUP_UI_TOOLBAR + "/run_overall"
# defaults
# server connection timeout in minutes
const DEFAULT_SERVER_TIMEOUT :int = 30
# test case runtime timeout in seconds
const DEFAULT_TEST_TIMEOUT :int = 60*5
# the folder to create new test-suites
const DEFAULT_TEST_LOOKUP_FOLDER := "test"
# help texts
const HELP_TEST_LOOKUP_FOLDER := "Sets the subfolder for the search/creation of test suites. (leave empty to use source folder)"
enum NAMING_CONVENTIONS {
AUTO_DETECT,
SNAKE_CASE,
PASCAL_CASE,
}
static func setup() -> void:
create_property_if_need(UPDATE_NOTIFICATION_ENABLED, true, "Enables/Disables the update notification checked startup.")
create_property_if_need(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT, "Sets the server connection timeout in minutes.")
create_property_if_need(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT, "Sets the test case runtime timeout in seconds.")
create_property_if_need(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER, HELP_TEST_LOOKUP_FOLDER)
create_property_if_need(TEST_SITE_NAMING_CONVENTION, NAMING_CONVENTIONS.AUTO_DETECT, "Sets test-suite genrate script name convention.", NAMING_CONVENTIONS.keys())
create_property_if_need(REPORT_PUSH_ERRORS, false, "Enables/Disables report of push_error() as failure!")
create_property_if_need(REPORT_SCRIPT_ERRORS, true, "Enables/Disables report of script errors as failure!")
create_property_if_need(REPORT_ORPHANS, true, "Enables/Disables orphan reporting.")
create_property_if_need(REPORT_ASSERT_ERRORS, true, "Enables/Disables error reporting checked asserts.")
create_property_if_need(REPORT_ASSERT_WARNINGS, true, "Enables/Disables warning reporting checked asserts")
create_property_if_need(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true, "Enabled/disabled number values will be compared strictly by type. (real vs int)")
create_property_if_need(INSPECTOR_NODE_COLLAPSE, true, "Enables/Disables that the testsuite node is closed after a successful test run.")
create_property_if_need(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, false, "Shows/Hides the 'Run overall Tests' button in the inspector toolbar.")
create_property_if_need(TEMPLATE_TS_GD, GdUnitTestSuiteTemplate.default_GD_template(), "Defines the test suite template")
create_shortcut_properties_if_need()
migrate_properties()
static func migrate_properties() -> void:
var TEST_ROOT_FOLDER := "gdunit4/settings/test/test_root_folder"
if get_property(TEST_ROOT_FOLDER) != null:
migrate_property(TEST_ROOT_FOLDER,\
TEST_LOOKUP_FOLDER,\
DEFAULT_TEST_LOOKUP_FOLDER,\
HELP_TEST_LOOKUP_FOLDER,\
func(value): return DEFAULT_TEST_LOOKUP_FOLDER if value == null else value)
static func create_shortcut_properties_if_need() -> void:
# inspector
create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS), "Rerun of the last tests performed.")
create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG), "Rerun of the last tests performed (Debug).")
create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL), "Runs all tests (Debug).")
create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_STOP, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.STOP_TEST_RUN), "Stops the current test execution.")
# script editor
create_property_if_need(SHORTCUT_EDITOR_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE), "Runs the currently selected test.")
create_property_if_need(SHORTCUT_EDITOR_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG), "Runs the currently selected test (Debug).")
create_property_if_need(SHORTCUT_EDITOR_CREATE_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.CREATE_TEST), "Creates a new test case for the currently selected function.")
# filesystem
create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.NONE), "Runs all test suites on the selected folder or file.")
create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.NONE), "Runs all test suites on the selected folder or file (Debug).")
static func create_property_if_need(name :String, default :Variant, help :="", value_set := PackedStringArray()) -> void:
if not ProjectSettings.has_setting(name):
#prints("GdUnit4: Set inital settings '%s' to '%s'." % [name, str(default)])
ProjectSettings.set_setting(name, default)
ProjectSettings.set_initial_value(name, default)
help += "" if value_set.is_empty() else " %s" % value_set
set_help(name, default, help)
static func set_help(property_name :String, value :Variant, help :String) -> void:
ProjectSettings.add_property_info({
"name": property_name,
"type": typeof(value),
"hint": PROPERTY_HINT_TYPE_STRING,
"hint_string": help
})
static func get_setting(name :String, default :Variant) -> Variant:
if ProjectSettings.has_setting(name):
return ProjectSettings.get_setting(name)
return default
static func is_update_notification_enabled() -> bool:
if ProjectSettings.has_setting(UPDATE_NOTIFICATION_ENABLED):
return ProjectSettings.get_setting(UPDATE_NOTIFICATION_ENABLED)
return false
static func set_update_notification(enable :bool) -> void:
ProjectSettings.set_setting(UPDATE_NOTIFICATION_ENABLED, enable)
ProjectSettings.save()
static func get_log_path() -> String:
return ProjectSettings.get_setting(STDOUT_WITE_TO_FILE)
static func set_log_path(path :String) -> void:
ProjectSettings.set_setting(STDOUT_ENABLE_TO_FILE, true)
ProjectSettings.set_setting(STDOUT_WITE_TO_FILE, path)
ProjectSettings.save()
# the configured server connection timeout in ms
static func server_timeout() -> int:
return get_setting(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT) * 60 * 1000
# the configured test case timeout in ms
static func test_timeout() -> int:
return get_setting(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT) * 1000
# the root folder to store/generate test-suites
static func test_root_folder() -> String:
return get_setting(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER)
static func is_verbose_assert_warnings() -> bool:
return get_setting(REPORT_ASSERT_WARNINGS, true)
static func is_verbose_assert_errors() -> bool:
return get_setting(REPORT_ASSERT_ERRORS, true)
static func is_verbose_orphans() -> bool:
return get_setting(REPORT_ORPHANS, true)
static func is_strict_number_type_compare() -> bool:
return get_setting(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true)
static func is_report_push_errors() -> bool:
return get_setting(REPORT_PUSH_ERRORS, false)
static func is_report_script_errors() -> bool:
return get_setting(REPORT_SCRIPT_ERRORS, true)
static func is_inspector_node_collapse() -> bool:
return get_setting(INSPECTOR_NODE_COLLAPSE, true)
static func is_inspector_toolbar_button_show() -> bool:
return get_setting(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, true)
static func is_log_enabled() -> bool:
return ProjectSettings.get_setting(STDOUT_ENABLE_TO_FILE)
static func list_settings(category :String) -> Array[GdUnitProperty]:
var settings :Array[GdUnitProperty] = []
for property in ProjectSettings.get_property_list():
var property_name :String = property["name"]
if property_name.begins_with(category):
var value :Variant = ProjectSettings.get_setting(property_name)
var default :Variant = ProjectSettings.property_get_revert(property_name)
var help :String = property["hint_string"]
var value_set := extract_value_set_from_help(help)
settings.append(GdUnitProperty.new(property_name, property["type"], value, default, help, value_set))
return settings
static func extract_value_set_from_help(value :String) -> PackedStringArray:
var regex := RegEx.new()
regex.compile("\\[(.+)\\]")
var matches := regex.search_all(value)
if matches.is_empty():
return PackedStringArray()
var values :String = matches[0].get_string(1)
return values.replacen(" ", "").replacen("\"", "").split(",", false)
static func update_property(property :GdUnitProperty) -> Variant:
var current_value :Variant = ProjectSettings.get_setting(property.name())
if current_value != property.value():
var error :Variant = validate_property_value(property)
if error != null:
return error
ProjectSettings.set_setting(property.name(), property.value())
GdUnitSignals.instance().gdunit_settings_changed.emit(property)
_save_settings()
return null
static func reset_property(property :GdUnitProperty) -> void:
ProjectSettings.set_setting(property.name(), property.default())
GdUnitSignals.instance().gdunit_settings_changed.emit(property)
_save_settings()
static func validate_property_value(property :GdUnitProperty) -> Variant:
match property.name():
TEST_LOOKUP_FOLDER:
return validate_lookup_folder(property.value())
_: return null
static func validate_lookup_folder(value :String) -> Variant:
if value.is_empty() or value == "/":
return null
if value.contains("res:"):
return "Test Lookup Folder: do not allowed to contains 'res://'"
if not value.is_valid_filename():
return "Test Lookup Folder: contains invalid characters! e.g (: / \\ ? * \" | % < >)"
return null
static func save_property(name :String, value :Variant) -> void:
ProjectSettings.set_setting(name, value)
_save_settings()
static func _save_settings() -> void:
var err := ProjectSettings.save()
if err != OK:
push_error("Save GdUnit4 settings failed : %s" % error_string(err))
return
static func has_property(name :String) -> bool:
return ProjectSettings.get_property_list().any(func(property :Dictionary) -> bool: return property["name"] == name)
static func get_property(name :String) -> GdUnitProperty:
for property in ProjectSettings.get_property_list():
var property_name :String = property["name"]
if property_name == name:
var value :Variant = ProjectSettings.get_setting(property_name)
var default :Variant = ProjectSettings.property_get_revert(property_name)
var help :String = property["hint_string"]
var value_set := extract_value_set_from_help(help)
return GdUnitProperty.new(property_name, property["type"], value, default, help, value_set)
return null
static func migrate_property(old_property :String, new_property :String, default_value :Variant, help :String, converter := Callable()) -> void:
var property := get_property(old_property)
if property == null:
prints("Migration not possible, property '%s' not found" % old_property)
return
var value :Variant = converter.call(property.value()) if converter.is_valid() else property.value()
ProjectSettings.set_setting(new_property, value)
ProjectSettings.set_initial_value(new_property, default_value)
set_help(new_property, value, help)
ProjectSettings.clear(old_property)
prints("Succesfull migrated property '%s' -> '%s' value: %s" % [old_property, new_property, value])
static func dump_to_tmp() -> void:
ProjectSettings.save_custom("user://project_settings.godot")
static func restore_dump_from_tmp() -> void:
DirAccess.copy_absolute("user://project_settings.godot", "res://project.godot")

View file

@ -0,0 +1,64 @@
class_name GdUnitSignalAwaiter
extends RefCounted
signal signal_emitted(action)
const NO_ARG :Variant = GdUnitConstants.NO_ARG
var _wait_on_idle_frame = false
var _interrupted := false
var _time_left := 0
var _timeout_millis :int
func _init(timeout_millis :int, wait_on_idle_frame := false):
_timeout_millis = timeout_millis
_wait_on_idle_frame = wait_on_idle_frame
func _on_signal_emmited(arg0=NO_ARG, arg1=NO_ARG, arg2=NO_ARG, arg3=NO_ARG, arg4=NO_ARG, arg5=NO_ARG, arg6=NO_ARG, arg7=NO_ARG, arg8=NO_ARG, arg9=NO_ARG):
var signal_args :Variant = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG)
signal_emitted.emit(signal_args)
func is_interrupted() -> bool:
return _interrupted
func elapsed_time() -> int:
return _time_left
func on_signal(source :Object, signal_name :String, expected_signal_args :Array) -> Variant:
# register checked signal to wait for
source.connect(signal_name, _on_signal_emmited)
# install timeout timer
var timer = Timer.new()
Engine.get_main_loop().root.add_child(timer)
timer.add_to_group("GdUnitTimers")
timer.set_one_shot(true)
timer.timeout.connect(func do_interrupt():
_interrupted = true
signal_emitted.emit(null)
, CONNECT_DEFERRED)
timer.start(_timeout_millis * 0.001 * Engine.get_time_scale())
# holds the emited value
var value :Variant
# wait for signal is emitted or a timeout is happen
while true:
value = await signal_emitted
if _interrupted:
break
if not (value is Array):
value = [value]
if expected_signal_args.size() == 0 or GdObjects.equals(value, expected_signal_args):
break
await Engine.get_main_loop().process_frame
source.disconnect(signal_name, _on_signal_emmited)
_time_left = timer.time_left
await Engine.get_main_loop().process_frame
if value is Array and value.size() == 1:
return value[0]
return value

View file

@ -0,0 +1,103 @@
# It connects to all signals of given emitter and collects received signals and arguments
# The collected signals are cleand finally when the emitter is freed.
class_name GdUnitSignalCollector
extends RefCounted
const NO_ARG :Variant = GdUnitConstants.NO_ARG
const SIGNAL_BLACK_LIST = []#["tree_exiting", "tree_exited", "child_exiting_tree"]
# {
# emitter<Object> : {
# signal_name<String> : [signal_args<Array>],
# ...
# }
# }
var _collected_signals :Dictionary = {}
func clear() -> void:
for emitter in _collected_signals.keys():
if is_instance_valid(emitter):
unregister_emitter(emitter)
# connect to all possible signals defined by the emitter
# prepares the signal collection to store received signals and arguments
func register_emitter(emitter :Object):
if is_instance_valid(emitter):
# check emitter is already registerd
if _collected_signals.has(emitter):
return
_collected_signals[emitter] = Dictionary()
# connect to 'tree_exiting' of the emitter to finally release all acquired resources/connections.
if emitter is Node and !emitter.tree_exiting.is_connected(unregister_emitter):
emitter.tree_exiting.connect(unregister_emitter.bind(emitter))
# connect to all signals of the emitter we want to collect
for signal_def in emitter.get_signal_list():
var signal_name = signal_def["name"]
# set inital collected to empty
if not is_signal_collecting(emitter, signal_name):
_collected_signals[emitter][signal_name] = Array()
if SIGNAL_BLACK_LIST.find(signal_name) != -1:
continue
if !emitter.is_connected(signal_name, _on_signal_emmited):
var err := emitter.connect(signal_name, _on_signal_emmited.bind(emitter, signal_name))
if err != OK:
push_error("Can't connect to signal %s on %s. Error: %s" % [signal_name, emitter, error_string(err)])
# unregister all acquired resources/connections, otherwise it ends up in orphans
# is called when the emitter is removed from the parent
func unregister_emitter(emitter :Object):
if is_instance_valid(emitter):
for signal_def in emitter.get_signal_list():
var signal_name = signal_def["name"]
if emitter.is_connected(signal_name, _on_signal_emmited):
emitter.disconnect(signal_name, _on_signal_emmited.bind(emitter, signal_name))
_collected_signals.erase(emitter)
# receives the signal from the emitter with all emitted signal arguments and additional the emitter and signal_name as last two arguements
func _on_signal_emmited( arg0=NO_ARG, arg1=NO_ARG, arg2=NO_ARG, arg3=NO_ARG, arg4=NO_ARG, arg5=NO_ARG, arg6=NO_ARG, arg7=NO_ARG, arg8=NO_ARG, arg9=NO_ARG, arg10=NO_ARG, arg11=NO_ARG):
var signal_args = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10,arg11], NO_ARG)
# extract the emitter and signal_name from the last two arguments (see line 61 where is added)
var signal_name :String = signal_args.pop_back()
var emitter :Object = signal_args.pop_back()
#prints("_on_signal_emmited:", emitter, signal_name, signal_args)
if is_signal_collecting(emitter, signal_name):
_collected_signals[emitter][signal_name].append(signal_args)
func reset_received_signals(emitter :Object, signal_name: String, signal_args :Array):
#_debug_signal_list("before claer");
if _collected_signals.has(emitter):
var signals_by_emitter = _collected_signals[emitter]
if signals_by_emitter.has(signal_name):
_collected_signals[emitter][signal_name].erase(signal_args)
#_debug_signal_list("after claer");
func is_signal_collecting(emitter :Object, signal_name :String) -> bool:
return _collected_signals.has(emitter) and _collected_signals[emitter].has(signal_name)
func match(emitter :Object, signal_name :String, args :Array) -> bool:
#prints("match", signal_name, _collected_signals[emitter][signal_name]);
if _collected_signals.is_empty() or not _collected_signals.has(emitter):
return false
for received_args in _collected_signals[emitter][signal_name]:
#prints("testing", signal_name, received_args, "vs", args)
if GdObjects.equals(received_args, args):
return true
return false
func _debug_signal_list(message :String):
prints("-----", message, "-------")
prints("senders {")
for emitter in _collected_signals:
prints("\t", emitter)
for signal_name in _collected_signals[emitter]:
var args = _collected_signals[emitter][signal_name]
prints("\t\t", signal_name, args)
prints("}")

View file

@ -0,0 +1,34 @@
class_name GdUnitSignals
extends RefCounted
signal gdunit_client_connected(client_id :int)
signal gdunit_client_disconnected(client_id :int)
signal gdunit_client_terminated()
signal gdunit_event(event :GdUnitEvent)
signal gdunit_event_debug(event :GdUnitEvent)
signal gdunit_add_test_suite(test_suite :GdUnitTestSuiteDto)
signal gdunit_message(message :String)
signal gdunit_report(execution_context_id :int, report :GdUnitReport)
signal gdunit_set_test_failed(is_failed :bool)
signal gdunit_settings_changed(property :GdUnitProperty)
const META_KEY := "GdUnitSignals"
static func instance() -> GdUnitSignals:
if Engine.has_meta(META_KEY):
return Engine.get_meta(META_KEY)
var instance_ := GdUnitSignals.new()
Engine.set_meta(META_KEY, instance_)
return instance_
static func dispose() -> void:
var signals := instance()
# cleanup connected signals
for signal_ in signals.get_signal_list():
for connection in signals.get_signal_connection_list(signal_["name"]):
connection["signal"].disconnect(connection["callable"])
Engine.remove_meta(META_KEY)

View file

@ -0,0 +1,49 @@
################################################################################
# Provides access to a global accessible singleton
#
# This is a workarount to the existing auto load singleton because of some bugs
# around plugin handling
################################################################################
class_name GdUnitSingleton
extends RefCounted
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const MEATA_KEY := "GdUnitSingletons"
static func instance(name :String, clazz :Callable) -> Variant:
if Engine.has_meta(name):
return Engine.get_meta(name)
var singleton :Variant = clazz.call()
Engine.set_meta(name, singleton)
GdUnitTools.prints_verbose("Register singleton '%s:%s'" % [name, singleton])
var singletons :PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
singletons.append(name)
Engine.set_meta(MEATA_KEY, singletons)
return singleton
static func unregister(p_singleton :String) -> void:
var singletons :PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
if singletons.has(p_singleton):
GdUnitTools.prints_verbose("\n Unregister singleton '%s'" % p_singleton);
var index := singletons.find(p_singleton)
singletons.remove_at(index)
var instance_ :Variant = Engine.get_meta(p_singleton)
GdUnitTools.prints_verbose(" Free singleton instance '%s:%s'" % [p_singleton, instance_])
GdUnitTools.free_instance(instance_)
Engine.remove_meta(p_singleton)
GdUnitTools.prints_verbose(" Successfully freed '%s'" % p_singleton)
Engine.set_meta(MEATA_KEY, singletons)
static func dispose() -> void:
# use a copy because unregister is modify the singletons array
var singletons := PackedStringArray(Engine.get_meta(MEATA_KEY, PackedStringArray()))
GdUnitTools.prints_verbose("----------------------------------------------------------------")
GdUnitTools.prints_verbose("Cleanup singletons %s" % singletons)
for singleton in singletons:
unregister(singleton)
Engine.remove_meta(MEATA_KEY)
GdUnitTools.prints_verbose("----------------------------------------------------------------")

View file

@ -0,0 +1,18 @@
class_name GdUnitTestSuiteBuilder
extends RefCounted
static func create(source :Script, line_number :int) -> GdUnitResult:
var test_suite_path := GdUnitTestSuiteScanner.resolve_test_suite_path(source.resource_path, GdUnitSettings.test_root_folder())
# we need to save and close the testsuite and source if is current opened before modify
ScriptEditorControls.save_an_open_script(source.resource_path)
ScriptEditorControls.save_an_open_script(test_suite_path, true)
if GdObjects.is_cs_script(source):
return GdUnit4CSharpApiLoader.create_test_suite(source.resource_path, line_number+1, test_suite_path)
var parser := GdScriptParser.new()
var lines := source.source_code.split("\n")
var current_line := lines[line_number]
var func_name := parser.parse_func_name(current_line)
if func_name.is_empty():
return GdUnitResult.error("No function found at line: %d." % line_number)
return GdUnitTestSuiteScanner.create_test_case(test_suite_path, func_name, source.resource_path)

View file

@ -0,0 +1,339 @@
class_name GdUnitTestSuiteScanner
extends RefCounted
const TEST_FUNC_TEMPLATE ="""
func test_${func_name}() -> void:
# remove this line and complete your test
assert_not_yet_implemented()
"""
# we exclude the gdunit source directorys by default
const exclude_scan_directories = [
"res://addons/gdUnit4/bin",
"res://addons/gdUnit4/src",
"res://reports"]
var _script_parser := GdScriptParser.new()
var _extends_test_suite_classes := Array()
var _expression_runner := GdUnitExpressionRunner.new()
func scan_testsuite_classes() -> void:
# scan and cache extends GdUnitTestSuite by class name an resource paths
_extends_test_suite_classes.append("GdUnitTestSuite")
if ProjectSettings.has_setting("_global_script_classes"):
var script_classes:Array = ProjectSettings.get_setting("_global_script_classes") as Array
for element in script_classes:
var script_meta = element as Dictionary
if script_meta["base"] == "GdUnitTestSuite":
_extends_test_suite_classes.append(script_meta["class"])
func scan(resource_path :String) -> Array[Node]:
scan_testsuite_classes()
# if single testsuite requested
if FileAccess.file_exists(resource_path):
var test_suite := _parse_is_test_suite(resource_path)
if test_suite != null:
return [test_suite]
return [] as Array[Node]
var base_dir := DirAccess.open(resource_path)
if base_dir == null:
prints("Given directory or file does not exists:", resource_path)
return []
return _scan_test_suites(base_dir, [])
func _scan_test_suites(dir :DirAccess, collected_suites :Array[Node]) -> Array[Node]:
if exclude_scan_directories.has(dir.get_current_dir()):
return collected_suites
prints("Scanning for test suites in:", dir.get_current_dir())
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file_name := dir.get_next()
while file_name != "":
var resource_path = GdUnitTestSuiteScanner._file(dir, file_name)
if dir.current_is_dir():
var sub_dir := DirAccess.open(resource_path)
if sub_dir != null:
_scan_test_suites(sub_dir, collected_suites)
else:
var time = LocalTime.now()
var test_suite := _parse_is_test_suite(resource_path)
if test_suite:
collected_suites.append(test_suite)
if OS.is_stdout_verbose() and time.elapsed_since_ms() > 300:
push_warning("Scanning of test-suite '%s' took more than 300ms: " % resource_path, time.elapsed_since())
file_name = dir.get_next()
return collected_suites
static func _file(dir :DirAccess, file_name :String) -> String:
var current_dir := dir.get_current_dir()
if current_dir.ends_with("/"):
return current_dir + file_name
return current_dir + "/" + file_name
func _parse_is_test_suite(resource_path :String) -> Node:
if not GdUnitTestSuiteScanner._is_script_format_supported(resource_path):
return null
if GdUnit4CSharpApiLoader.is_test_suite(resource_path):
return GdUnit4CSharpApiLoader.parse_test_suite(resource_path)
var script :Script = ResourceLoader.load(resource_path)
if not GdObjects.is_test_suite(script):
return null
if GdObjects.is_gd_script(script):
return _parse_test_suite(script)
return null
static func _is_script_format_supported(resource_path :String) -> bool:
var ext := resource_path.get_extension()
if ext == "gd":
return true
return GdUnit4CSharpApiLoader.is_csharp_file(resource_path)
func _parse_test_suite(script :GDScript) -> GdUnitTestSuite:
# find all test cases
var test_case_names := _extract_test_case_names(script)
# test suite do not contains any tests
if test_case_names.is_empty():
push_warning("The test suite %s do not contain any tests, it excludes from discovery." % script.resource_path)
return null;
var test_suite = script.new()
test_suite.set_name(GdUnitTestSuiteScanner.parse_test_suite_name(script))
# add test cases to test suite and parse test case line nummber
_parse_and_add_test_cases(test_suite, script, test_case_names)
# not all test case parsed?
# we have to scan the base class to
if not test_case_names.is_empty():
var base_script :GDScript = test_suite.get_script().get_base_script()
while base_script is GDScript:
# do not parse testsuite itself
if base_script.resource_path.find("GdUnitTestSuite") == -1:
_parse_and_add_test_cases(test_suite, base_script, test_case_names)
base_script = base_script.get_base_script()
return test_suite
func _extract_test_case_names(script :GDScript) -> PackedStringArray:
var names := PackedStringArray()
for method in script.get_script_method_list():
var funcName :String = method["name"]
if funcName.begins_with("test"):
names.append(funcName)
return names
static func parse_test_suite_name(script :Script) -> String:
return script.resource_path.get_file().replace(".gd", "")
func _handle_test_suite_arguments(test_suite, script :GDScript, fd :GdFunctionDescriptor):
for arg in fd.args():
match arg.name():
_TestCase.ARGUMENT_SKIP:
var result = _expression_runner.execute(script, arg.value_as_string())
if result is bool:
test_suite.__is_skipped = result
else:
push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.value_as_string())
_TestCase.ARGUMENT_SKIP_REASON:
test_suite.__skip_reason = arg.value_as_string()
_:
push_error("Unsuported argument `%s` found on before() at '%s'!" % [arg.name(), script.resource_path])
func _handle_test_case_arguments(test_suite, script :GDScript, fd :GdFunctionDescriptor):
var timeout := _TestCase.DEFAULT_TIMEOUT
var iterations := Fuzzer.ITERATION_DEFAULT_COUNT
var seed_value := -1
var is_skipped := false
var skip_reason := "Unknown."
var fuzzers :Array[GdFunctionArgument] = []
var test := _TestCase.new()
for arg in fd.args():
# verify argument is allowed
# is test using fuzzers?
if arg.type() == GdObjects.TYPE_FUZZER:
fuzzers.append(arg)
elif arg.has_default():
match arg.name():
_TestCase.ARGUMENT_TIMEOUT:
timeout = arg.default()
_TestCase.ARGUMENT_SKIP:
var result = _expression_runner.execute(script, arg.value_as_string())
if result is bool:
is_skipped = result
else:
push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.value_as_string())
_TestCase.ARGUMENT_SKIP_REASON:
skip_reason = arg.value_as_string()
Fuzzer.ARGUMENT_ITERATIONS:
iterations = arg.default()
Fuzzer.ARGUMENT_SEED:
seed_value = arg.default()
# create new test
test.configure(fd.name(), fd.line_number(), script.resource_path, timeout, fuzzers, iterations, seed_value)
test.set_function_descriptor(fd)
test.skip(is_skipped, skip_reason)
_validate_argument(fd, test)
test_suite.add_child(test)
func _parse_and_add_test_cases(test_suite, script :GDScript, test_case_names :PackedStringArray):
var test_cases_to_find = Array(test_case_names)
var functions_to_scan := test_case_names.duplicate()
functions_to_scan.append("before")
var source := _script_parser.load_source_code(script, [script.resource_path])
var function_descriptors := _script_parser.parse_functions(source, "", [script.resource_path], functions_to_scan)
for fd in function_descriptors:
if fd.name() == "before":
_handle_test_suite_arguments(test_suite, script, fd)
if test_cases_to_find.has(fd.name()):
_handle_test_case_arguments(test_suite, script, fd)
const TEST_CASE_ARGUMENTS = [_TestCase.ARGUMENT_TIMEOUT, _TestCase.ARGUMENT_SKIP, _TestCase.ARGUMENT_SKIP_REASON, Fuzzer.ARGUMENT_ITERATIONS, Fuzzer.ARGUMENT_SEED]
func _validate_argument(fd :GdFunctionDescriptor, test_case :_TestCase) -> void:
if fd.is_parameterized():
return
for argument in fd.args():
if argument.type() == GdObjects.TYPE_FUZZER or argument.name() in TEST_CASE_ARGUMENTS:
continue
test_case.skip(true, "Unknown test case argument '%s' found." % argument.name())
# converts given file name by configured naming convention
static func _to_naming_convention(file_name :String) -> String:
var nc :int = GdUnitSettings.get_setting(GdUnitSettings.TEST_SITE_NAMING_CONVENTION, 0)
match nc:
GdUnitSettings.NAMING_CONVENTIONS.AUTO_DETECT:
if GdObjects.is_snake_case(file_name):
return GdObjects.to_snake_case(file_name + "Test")
return GdObjects.to_pascal_case(file_name + "Test")
GdUnitSettings.NAMING_CONVENTIONS.SNAKE_CASE:
return GdObjects.to_snake_case(file_name + "Test")
GdUnitSettings.NAMING_CONVENTIONS.PASCAL_CASE:
return GdObjects.to_pascal_case(file_name + "Test")
push_error("Unexpected case")
return "-<Unexpected>-"
static func resolve_test_suite_path(source_script_path :String, test_root_folder :String = "test") -> String:
var file_name = source_script_path.get_basename().get_file()
var suite_name := _to_naming_convention(file_name)
if test_root_folder.is_empty() or test_root_folder == "/":
return source_script_path.replace(file_name, suite_name)
# is user tmp
if source_script_path.begins_with("user://tmp"):
return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name)
# at first look up is the script under a "src" folder located
var test_suite_path :String
var src_folder = source_script_path.find("/src/")
if src_folder != -1:
test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/")
else:
var paths = source_script_path.split("/", false)
# is a plugin script?
if paths[1] == "addons":
test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder]
# rebuild plugin path
for index in range(3, paths.size()):
test_suite_path += "/" + paths[index]
else:
test_suite_path = paths[0] + "//" + test_root_folder
for index in range(1, paths.size()):
test_suite_path += "/" + paths[index]
return normalize_path(test_suite_path).replace(file_name, suite_name)
static func normalize_path(path :String) -> String:
return path.replace("///", "/")
static func create_test_suite(test_suite_path :String, source_path :String) -> GdUnitResult:
# create directory if not exists
if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()):
var error := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir())
if error != OK:
return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error])
var script := GDScript.new()
script.source_code = GdUnitTestSuiteTemplate.build_template(source_path)
var error := ResourceSaver.save(script, test_suite_path)
if error != OK:
return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error])
return GdUnitResult.success(test_suite_path)
static func get_test_case_line_number(resource_path :String, func_name :String) -> int:
var file := FileAccess.open(resource_path, FileAccess.READ)
if file != null:
var script_parser := GdScriptParser.new()
var line_number := 0
while not file.eof_reached():
var row := GdScriptParser.clean_up_row(file.get_line())
line_number += 1
# ignore comments and empty lines and not test functions
if row.begins_with("#") || row.length() == 0 || row.find("functest") == -1:
continue
# abort if test case name found
if script_parser.parse_func_name(row) == "test_" + func_name:
return line_number
return -1
static func add_test_case(resource_path :String, func_name :String) -> GdUnitResult:
var script := load(resource_path) as GDScript
# count all exiting lines and add two as space to add new test case
var line_number := count_lines(script) + 2
var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name)
if Engine.is_editor_hint():
var ep :EditorPlugin = Engine.get_meta("GdUnitEditorPlugin")
var settings := ep.get_editor_interface().get_editor_settings()
var ident_type :int = settings.get_setting("text_editor/behavior/indent/type")
var ident_size :int = settings.get_setting("text_editor/behavior/indent/size")
if ident_type == 1:
func_body = func_body.replace(" ", "".lpad(ident_size, " "))
script.source_code += func_body
var error := ResourceSaver.save(script, resource_path)
if error != OK:
return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error])
return GdUnitResult.success({ "path" : resource_path, "line" : line_number})
static func count_lines(script : GDScript) -> int:
return script.source_code.split("\n").size()
static func test_suite_exists(test_suite_path :String) -> bool:
return FileAccess.file_exists(test_suite_path)
static func test_case_exists(test_suite_path :String, func_name :String) -> bool:
if not test_suite_exists(test_suite_path):
return false
var script := ResourceLoader.load(test_suite_path) as GDScript
for f in script.get_script_method_list():
if f["name"] == "test_" + func_name:
return true
return false
static func create_test_case(test_suite_path :String, func_name :String, source_script_path :String) -> GdUnitResult:
if test_case_exists(test_suite_path, func_name):
var line_number := get_test_case_line_number(test_suite_path, func_name)
return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number})
if not test_suite_exists(test_suite_path):
var result := create_test_suite(test_suite_path, source_script_path)
if result.is_error():
return result
return add_test_case(test_suite_path, func_name)

View file

@ -0,0 +1,111 @@
extends RefCounted
static func normalize_text(text :String) -> String:
return text.replace("\r", "");
static func richtext_normalize(input :String) -> String:
return GdUnitSingleton.instance("regex_richtext", func _regex_richtext() -> RegEx:
return to_regex("\\[/?(b|color|bgcolor|right|table|cell).*?\\]") )\
.sub(input, "", true).replace("\r", "")
static func to_regex(pattern :String) -> RegEx:
var regex := RegEx.new()
var err := regex.compile(pattern)
if err != OK:
push_error("Can't compiling regx '%s'.\n ERROR: %s" % [pattern, error_string(err)])
return regex
static func prints_verbose(message :String) -> void:
if OS.is_stdout_verbose():
prints(message)
static func free_instance(instance :Variant, is_stdout_verbose :=false) -> bool:
if instance is Array:
for element in instance:
free_instance(element)
instance.clear()
return true
# do not free an already freed instance
if not is_instance_valid(instance):
return false
# do not free a class refernece
if typeof(instance) == TYPE_OBJECT and (instance as Object).is_class("GDScriptNativeClass"):
return false
if is_stdout_verbose:
print_verbose("GdUnit4:gc():free instance ", instance)
release_double(instance)
if instance is RefCounted:
instance.notification(Object.NOTIFICATION_PREDELETE)
await Engine.get_main_loop().process_frame
await Engine.get_main_loop().physics_frame
return true
else:
# is instance already freed?
#if not is_instance_valid(instance) or ClassDB.class_get_property(instance, "new"):
# return false
#release_connections(instance)
if instance is Timer:
instance.stop()
instance.call_deferred("free")
await Engine.get_main_loop().process_frame
return true
if instance is Node and instance.get_parent() != null:
if is_stdout_verbose:
print_verbose("GdUnit4:gc():remove node from parent ", instance.get_parent(), instance)
instance.get_parent().remove_child(instance)
instance.set_owner(null)
instance.free()
return !is_instance_valid(instance)
static func _release_connections(instance :Object) -> void:
if is_instance_valid(instance):
# disconnect from all connected signals to force freeing, otherwise it ends up in orphans
for connection in instance.get_incoming_connections():
var signal_ :Signal = connection["signal"]
var callable_ :Callable = connection["callable"]
#prints(instance, connection)
#prints("signal", signal_.get_name(), signal_.get_object())
#prints("callable", callable_.get_object())
if instance.has_signal(signal_.get_name()) and instance.is_connected(signal_.get_name(), callable_):
#prints("disconnect signal", signal_.get_name(), callable_)
instance.disconnect(signal_.get_name(), callable_)
release_timers()
static func release_timers() -> void:
# we go the new way to hold all gdunit timers in group 'GdUnitTimers'
for node in Engine.get_main_loop().root.get_children():
if is_instance_valid(node) and node.is_in_group("GdUnitTimers"):
if is_instance_valid(node):
Engine.get_main_loop().root.remove_child(node)
node.stop()
node.free()
# the finally cleaup unfreed resources and singletons
static func dispose_all() -> void:
release_timers()
GdUnitSignals.dispose()
GdUnitSingleton.dispose()
# if instance an mock or spy we need manually freeing the self reference
static func release_double(instance :Object) -> void:
if instance.has_method("__release_double"):
instance.call("__release_double")
static func clear_push_errors() -> void:
var runner :Node = Engine.get_meta("GdUnitRunner")
if runner != null:
runner.clear_push_errors()
static func register_expect_interupted_by_timeout(test_suite :Node, test_case_name :String) -> void:
var test_case :Node = test_suite.find_child(test_case_name, false, false)
test_case.expect_to_interupt()

View file

@ -0,0 +1,21 @@
## This service class contains helpers to wrap Godot functions and handle them carefully depending on the current Godot version
class_name GodotVersionFixures
extends RefCounted
## Returns the icon property defined by name and theme_type, if it exists.
static func get_icon(control :Control, icon_name :String) -> Texture2D:
if Engine.get_version_info().hex >= 040200:
return control.get_theme_icon(icon_name, "EditorIcons")
return control.theme.get_icon(icon_name, "EditorIcons")
@warning_ignore("shadowed_global_identifier")
static func type_convert(value: Variant, type: int):
return convert(value, type)
@warning_ignore("shadowed_global_identifier")
static func convert(value: Variant, type: int) -> Variant:
return type_convert(value, type)

View file

@ -0,0 +1,110 @@
# This class provides Date/Time functionallity to Godot
class_name LocalTime
extends Resource
enum TimeUnit {
MILLIS = 1,
SECOND = 2,
MINUTE = 3,
HOUR = 4,
DAY = 5,
MONTH = 6,
YEAR = 7
}
const SECONDS_PER_MINUTE:int = 60
const MINUTES_PER_HOUR:int = 60
const HOURS_PER_DAY:int = 24
const MILLIS_PER_SECOND:int = 1000
const MILLIS_PER_MINUTE:int = MILLIS_PER_SECOND * SECONDS_PER_MINUTE
const MILLIS_PER_HOUR:int = MILLIS_PER_MINUTE * MINUTES_PER_HOUR
var _time :int
var _hour :int
var _minute :int
var _second :int
var _millisecond :int
static func now() -> LocalTime:
return LocalTime.new(_get_system_time_msecs())
static func of_unix_time(time_ms :int) -> LocalTime:
return LocalTime.new(time_ms)
static func local_time(hours :int, minutes :int, seconds :int, milliseconds :int) -> LocalTime:
return LocalTime.new(MILLIS_PER_HOUR * hours\
+ MILLIS_PER_MINUTE * minutes\
+ MILLIS_PER_SECOND * seconds\
+ milliseconds)
func elapsed_since() -> String:
return LocalTime.elapsed(LocalTime._get_system_time_msecs() - _time)
func elapsed_since_ms() -> int:
return LocalTime._get_system_time_msecs() - _time
func plus(time_unit :TimeUnit, value :int) -> LocalTime:
var addValue:int = 0
match time_unit:
TimeUnit.MILLIS:
addValue = value
TimeUnit.SECOND:
addValue = value * MILLIS_PER_SECOND
TimeUnit.MINUTE:
addValue = value * MILLIS_PER_MINUTE
TimeUnit.HOUR:
addValue = value * MILLIS_PER_HOUR
_init(_time + addValue)
return self
static func elapsed(p_time_ms :int) -> String:
var local_time_ := LocalTime.new(p_time_ms)
if local_time_._hour > 0:
return "%dh %dmin %ds %dms" % [local_time_._hour, local_time_._minute, local_time_._second, local_time_._millisecond]
if local_time_._minute > 0:
return "%dmin %ds %dms" % [local_time_._minute, local_time_._second, local_time_._millisecond]
if local_time_._second > 0:
return "%ds %dms" % [local_time_._second, local_time_._millisecond]
return "%dms" % local_time_._millisecond
@warning_ignore("integer_division")
# create from epoch timestamp in ms
func _init(time :int):
_time = time
_hour = (time / MILLIS_PER_HOUR) % 24
_minute = (time / MILLIS_PER_MINUTE) % 60
_second = (time / MILLIS_PER_SECOND) % 60
_millisecond = time % 1000
func hour() -> int:
return _hour
func minute() -> int:
return _minute
func second() -> int:
return _second
func millis() -> int:
return _millisecond
func _to_string() -> String:
return "%02d:%02d:%02d.%03d" % [_hour, _minute, _second, _millisecond]
# wraper to old OS.get_system_time_msecs() function
static func _get_system_time_msecs() -> int:
return Time.get_unix_time_from_system() * 1000 as int

View file

@ -0,0 +1,238 @@
class_name _TestCase
extends Node
signal completed()
# default timeout 5min
const DEFAULT_TIMEOUT := -1
const ARGUMENT_TIMEOUT := "timeout"
const ARGUMENT_SKIP := "do_skip"
const ARGUMENT_SKIP_REASON := "skip_reason"
var _iterations: int = 1
var _current_iteration: int = -1
var _seed: int
var _fuzzers: Array[GdFunctionArgument] = []
var _test_param_index := -1
var _line_number: int = -1
var _script_path: String
var _skipped := false
var _skip_reason := ""
var _expect_to_interupt := false
var _timer: Timer
var _interupted: bool = false
var _failed := false
var _report: GdUnitReport = null
var _parameter_set_resolver: GdUnitTestParameterSetResolver
var _is_disposed := false
var timeout: int = DEFAULT_TIMEOUT:
set(value):
timeout = value
get:
if timeout == DEFAULT_TIMEOUT:
timeout = GdUnitSettings.test_timeout()
return timeout
@warning_ignore("shadowed_variable_base_class")
func configure(p_name: String, p_line_number: int, p_script_path: String, p_timeout: int=DEFAULT_TIMEOUT, p_fuzzers: Array[GdFunctionArgument]=[], p_iterations: int=1, p_seed: int=-1) -> _TestCase:
set_name(p_name)
_line_number = p_line_number
_fuzzers = p_fuzzers
_iterations = p_iterations
_seed = p_seed
_script_path = p_script_path
timeout = p_timeout
return self
func execute(p_test_parameter:=Array(), p_iteration:=0):
_failure_received(false)
_current_iteration = p_iteration - 1
if _current_iteration == - 1:
_set_failure_handler()
set_timeout()
if not p_test_parameter.is_empty():
update_fuzzers(p_test_parameter, p_iteration)
_execute_test_case(name, p_test_parameter)
else:
_execute_test_case(name, [])
await completed
func execute_paramaterized(p_test_parameter: Array):
_failure_received(false)
set_timeout()
# We need here to add a empty array to override the `test_parameters` to prevent initial "default" parameters from being used.
# This prevents objects in the argument list from being unnecessarily re-instantiated.
var test_parameters := p_test_parameter.duplicate() # is strictly need to duplicate the paramters before extend
test_parameters.append([])
_execute_test_case(name, test_parameters)
await completed
func dispose():
if _is_disposed:
return
_is_disposed = true
Engine.remove_meta("GD_TEST_FAILURE")
stop_timer()
_remove_failure_handler()
_fuzzers.clear()
_report = null
@warning_ignore("shadowed_variable_base_class", "redundant_await")
func _execute_test_case(name: String, test_parameter: Array):
# needs at least on await otherwise it breaks the awaiting chain
await get_parent().callv(name, test_parameter)
await Engine.get_main_loop().process_frame
completed.emit()
func update_fuzzers(input_values: Array, iteration: int):
for fuzzer in input_values:
if fuzzer is Fuzzer:
fuzzer._iteration_index = iteration + 1
func set_timeout():
if is_instance_valid(_timer):
return
var time: float = timeout / 1000.0
_timer = Timer.new()
add_child(_timer)
_timer.set_name("gdunit_test_case_timer_%d" % _timer.get_instance_id())
_timer.timeout.connect(func do_interrupt():
if is_fuzzed():
_report = GdUnitReport.new().create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.fuzzer_interuped(_current_iteration, "timedout"))
else:
_report = GdUnitReport.new().create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.test_timeout(timeout))
_interupted = true
completed.emit()
, CONNECT_DEFERRED)
_timer.set_one_shot(true)
_timer.set_wait_time(time)
_timer.set_autostart(false)
_timer.start()
func _set_failure_handler() -> void:
if not GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received):
GdUnitSignals.instance().gdunit_set_test_failed.connect(_failure_received)
func _remove_failure_handler() -> void:
if GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received):
GdUnitSignals.instance().gdunit_set_test_failed.disconnect(_failure_received)
func _failure_received(is_failed: bool) -> void:
# is already failed?
if _failed:
return
_failed = is_failed
Engine.set_meta("GD_TEST_FAILURE", is_failed)
func stop_timer():
# finish outstanding timeouts
if is_instance_valid(_timer):
_timer.stop()
_timer.call_deferred("free")
_timer = null
func expect_to_interupt() -> void:
_expect_to_interupt = true
func is_interupted() -> bool:
return _interupted
func is_expect_interupted() -> bool:
return _expect_to_interupt
func is_parameterized() -> bool:
return _parameter_set_resolver.is_parameterized()
func is_skipped() -> bool:
return _skipped
func report() -> GdUnitReport:
return _report
func skip_info() -> String:
return _skip_reason
func line_number() -> int:
return _line_number
func iterations() -> int:
return _iterations
func seed_value() -> int:
return _seed
func is_fuzzed() -> bool:
return not _fuzzers.is_empty()
func fuzzer_arguments() -> Array[GdFunctionArgument]:
return _fuzzers
func script_path() -> String:
return _script_path
func ResourcePath() -> String:
return _script_path
func generate_seed() -> void:
if _seed != -1:
seed(_seed)
func skip(skipped: bool, reason: String="") -> void:
_skipped = skipped
_skip_reason = reason
func set_function_descriptor(fd: GdFunctionDescriptor) -> void:
_parameter_set_resolver = GdUnitTestParameterSetResolver.new(fd)
func set_test_parameter_index(index: int) -> void:
_test_param_index = index
func test_parameter_index() -> int:
return _test_param_index
func test_case_names() -> PackedStringArray:
return _parameter_set_resolver.build_test_case_names(self)
func load_parameter_sets() -> Array:
return _parameter_set_resolver.load_parameter_sets(self, true)
func parameter_set_resolver() -> GdUnitTestParameterSetResolver:
return _parameter_set_resolver
func _to_string():
return "%s :%d (%dms)" % [get_name(), _line_number, timeout]

View file

@ -0,0 +1,41 @@
class_name GdUnitCommand
extends RefCounted
func _init(p_name :String, p_is_enabled: Callable, p_runnable: Callable, p_shortcut :GdUnitShortcut.ShortCut = GdUnitShortcut.ShortCut.NONE):
assert(p_name != null, "(%s) missing parameter 'name'" % p_name)
assert(p_is_enabled != null, "(%s) missing parameter 'is_enabled'" % p_name)
assert(p_runnable != null, "(%s) missing parameter 'runnable'" % p_name)
assert(p_shortcut != null, "(%s) missing parameter 'shortcut'" % p_name)
self.name = p_name
self.is_enabled = p_is_enabled
self.shortcut = p_shortcut
self.runnable = p_runnable
var name: String:
set(value):
name = value
get:
return name
var shortcut: GdUnitShortcut.ShortCut:
set(value):
shortcut = value
get:
return shortcut
var is_enabled: Callable:
set(value):
is_enabled = value
get:
return is_enabled
var runnable: Callable:
set(value):
runnable = value
get:
return runnable

View file

@ -0,0 +1,357 @@
class_name GdUnitCommandHandler
extends RefCounted
signal gdunit_runner_start()
signal gdunit_runner_stop(client_id :int)
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const CMD_RUN_OVERALL = "Debug Overall TestSuites"
const CMD_RUN_TESTCASE = "Run TestCases"
const CMD_RUN_TESTCASE_DEBUG = "Run TestCases (Debug)"
const CMD_RUN_TESTSUITE = "Run TestSuites"
const CMD_RUN_TESTSUITE_DEBUG = "Run TestSuites (Debug)"
const CMD_RERUN_TESTS = "ReRun Tests"
const CMD_RERUN_TESTS_DEBUG = "ReRun Tests (Debug)"
const CMD_STOP_TEST_RUN = "Stop Test Run"
const CMD_CREATE_TESTCASE = "Create TestCase"
const SETTINGS_SHORTCUT_MAPPING := {
"N/A" : GdUnitShortcut.ShortCut.NONE,
GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST : GdUnitShortcut.ShortCut.RERUN_TESTS,
GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG : GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG,
GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_OVERALL : GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL,
GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_STOP : GdUnitShortcut.ShortCut.STOP_TEST_RUN,
GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST : GdUnitShortcut.ShortCut.RUN_TESTCASE,
GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST_DEBUG : GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG,
GdUnitSettings.SHORTCUT_EDITOR_CREATE_TEST : GdUnitShortcut.ShortCut.CREATE_TEST,
GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST : GdUnitShortcut.ShortCut.RUN_TESTCASE,
GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG : GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG
}
var _editor_interface :EditorInterface
# the current test runner config
var _runner_config := GdUnitRunnerConfig.new()
# holds the current connected gdUnit runner client id
var _client_id :int
# if no debug mode we have an process id
var _current_runner_process_id :int = 0
# hold is current an test running
var _is_running :bool = false
# holds if the current running tests started in debug mode
var _running_debug_mode :bool
var _commands := {}
var _shortcuts := {}
static func instance() -> GdUnitCommandHandler:
return GdUnitSingleton.instance("GdUnitCommandHandler", func(): return GdUnitCommandHandler.new())
func _init():
assert_shortcut_mappings(SETTINGS_SHORTCUT_MAPPING)
if Engine.is_editor_hint():
var editor :EditorPlugin = Engine.get_meta("GdUnitEditorPlugin")
_editor_interface = editor.get_editor_interface()
GdUnitSignals.instance().gdunit_event.connect(_on_event)
GdUnitSignals.instance().gdunit_client_connected.connect(_on_client_connected)
GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_client_disconnected)
GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
# preload previous test execution
_runner_config.load_config()
init_shortcuts()
var is_running = func(_script :Script) : return _is_running
var is_not_running = func(_script :Script) : return !_is_running
register_command(GdUnitCommand.new(CMD_RUN_OVERALL, is_not_running, cmd_run_overall.bind(true), GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL))
register_command(GdUnitCommand.new(CMD_RUN_TESTCASE, is_not_running, cmd_editor_run_test.bind(false), GdUnitShortcut.ShortCut.RUN_TESTCASE))
register_command(GdUnitCommand.new(CMD_RUN_TESTCASE_DEBUG, is_not_running, cmd_editor_run_test.bind(true), GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG))
register_command(GdUnitCommand.new(CMD_RUN_TESTSUITE, is_not_running, cmd_run_test_suites.bind(false)))
register_command(GdUnitCommand.new(CMD_RUN_TESTSUITE_DEBUG, is_not_running, cmd_run_test_suites.bind(true)))
register_command(GdUnitCommand.new(CMD_RERUN_TESTS, is_not_running, cmd_run.bind(false), GdUnitShortcut.ShortCut.RERUN_TESTS))
register_command(GdUnitCommand.new(CMD_RERUN_TESTS_DEBUG, is_not_running, cmd_run.bind(true), GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG))
register_command(GdUnitCommand.new(CMD_CREATE_TESTCASE, is_not_running, cmd_create_test, GdUnitShortcut.ShortCut.CREATE_TEST))
register_command(GdUnitCommand.new(CMD_STOP_TEST_RUN, is_running, cmd_stop.bind(_client_id), GdUnitShortcut.ShortCut.STOP_TEST_RUN))
func _notification(what):
if what == NOTIFICATION_PREDELETE:
_commands.clear()
_shortcuts.clear()
func _do_process() -> void:
check_test_run_stopped_manually()
# is checking if the user has press the editor stop scene
func check_test_run_stopped_manually():
if is_test_running_but_stop_pressed():
if GdUnitSettings.is_verbose_assert_warnings():
push_warning("Test Runner scene was stopped manually, force stopping the current test run!")
cmd_stop(_client_id)
func is_test_running_but_stop_pressed():
return _editor_interface and _running_debug_mode and _is_running and not _editor_interface.is_playing_scene()
func assert_shortcut_mappings(mappings :Dictionary) -> void:
for shortcut in GdUnitShortcut.ShortCut.values():
assert(mappings.values().has(shortcut), "missing settings mapping for shortcut '%s'!" % GdUnitShortcut.ShortCut.keys()[shortcut])
func init_shortcuts() -> void:
for shortcut in GdUnitShortcut.ShortCut.values():
if shortcut == GdUnitShortcut.ShortCut.NONE:
continue
var property_name :String = SETTINGS_SHORTCUT_MAPPING.find_key(shortcut)
var property := GdUnitSettings.get_property(property_name)
var keys := GdUnitShortcut.default_keys(shortcut)
if property != null:
keys = property.value()
var inputEvent := create_shortcut_input_even(keys)
register_shortcut(shortcut, inputEvent)
func create_shortcut_input_even(key_codes : PackedInt32Array) -> InputEventKey:
var inputEvent :InputEventKey = InputEventKey.new()
inputEvent.pressed = true
for key_code in key_codes:
match key_code:
KEY_ALT:
inputEvent.alt_pressed = true
KEY_SHIFT:
inputEvent.shift_pressed = true
KEY_CTRL:
inputEvent.ctrl_pressed = true
_:
inputEvent.keycode = key_code as Key
inputEvent.physical_keycode = key_code as Key
return inputEvent
func register_shortcut(p_shortcut :GdUnitShortcut.ShortCut, p_input_event :InputEvent) -> void:
GdUnitTools.prints_verbose("register shortcut: '%s' to '%s'" % [GdUnitShortcut.ShortCut.keys()[p_shortcut], p_input_event.as_text()])
var shortcut := Shortcut.new()
shortcut.set_events([p_input_event])
var command_name :String = get_shortcut_command(p_shortcut)
_shortcuts[p_shortcut] = GdUnitShortcutAction.new(p_shortcut, shortcut, command_name)
func get_shortcut(shortcut_type :GdUnitShortcut.ShortCut) -> Shortcut:
return get_shortcut_action(shortcut_type).shortcut
func get_shortcut_action(shortcut_type :GdUnitShortcut.ShortCut) -> GdUnitShortcutAction:
return _shortcuts.get(shortcut_type)
func get_shortcut_command(p_shortcut :GdUnitShortcut.ShortCut) -> String:
return GdUnitShortcut.CommandMapping.get(p_shortcut, "unknown command")
func register_command(p_command :GdUnitCommand) -> void:
_commands[p_command.name] = p_command
func command(cmd_name :String) -> GdUnitCommand:
return _commands.get(cmd_name)
func cmd_run_test_suites(test_suite_paths :PackedStringArray, debug :bool, rerun := false) -> void:
# create new runner runner_config for fresh run otherwise use saved one
if not rerun:
var result := _runner_config.clear()\
.add_test_suites(test_suite_paths)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run_test_case(test_suite_resource_path :String, test_case :String, test_param_index :int, debug :bool, rerun := false) -> void:
# create new runner config for fresh run otherwise use saved one
if not rerun:
var result := _runner_config.clear()\
.add_test_case(test_suite_resource_path, test_case, test_param_index)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run_overall(debug :bool) -> void:
var test_suite_paths :PackedStringArray = GdUnitCommandHandler.scan_test_directorys("res://" , GdUnitSettings.test_root_folder(), [])
var result := _runner_config.clear()\
.add_test_suites(test_suite_paths)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run(debug :bool) -> void:
# don't start is already running
if _is_running:
return
# save current selected excution config
var result := _runner_config.set_server_port(Engine.get_meta("gdunit_server_port")).save_config()
if result.is_error():
push_error(result.error_message())
return
# before start we have to save all changes
ScriptEditorControls.save_all_open_script()
gdunit_runner_start.emit()
_current_runner_process_id = -1
_running_debug_mode = debug
if debug:
run_debug_mode()
else:
run_release_mode()
func cmd_stop(client_id :int) -> void:
# don't stop if is already stopped
if not _is_running:
return
_is_running = false
gdunit_runner_stop.emit(client_id)
if _running_debug_mode:
_editor_interface.stop_playing_scene()
else: if _current_runner_process_id > 0:
var result = OS.kill(_current_runner_process_id)
if result != OK:
push_error("ERROR checked stopping GdUnit Test Runner. error code: %s" % result)
_current_runner_process_id = -1
func cmd_editor_run_test(debug :bool):
var cursor_line := active_base_editor().get_caret_line()
#run test case?
var regex := RegEx.new()
regex.compile("(^func[ ,\t])(test_[a-zA-Z0-9_]*)")
var result := regex.search(active_base_editor().get_line(cursor_line))
if result:
var func_name := result.get_string(2).strip_edges()
prints("Run test:", func_name, "debug", debug)
if func_name.begins_with("test_"):
cmd_run_test_case(active_script().resource_path, func_name, -1, debug)
return
# otherwise run the full test suite
var selected_test_suites := [active_script().resource_path]
cmd_run_test_suites(selected_test_suites, debug)
func cmd_create_test() -> void:
var cursor_line := active_base_editor().get_caret_line()
var result := GdUnitTestSuiteBuilder.create(active_script(), cursor_line)
if result.is_error():
# show error dialog
push_error("Failed to create test case: %s" % result.error_message())
return
var info := result.value() as Dictionary
ScriptEditorControls.edit_script(info.get("path"), info.get("line"))
static func scan_test_directorys(base_directory :String, test_directory: String, test_suite_paths :PackedStringArray) -> PackedStringArray:
print_verbose("Scannning for test directory '%s' at %s" % [test_directory, base_directory])
for directory in DirAccess.get_directories_at(base_directory):
if directory.begins_with("."):
continue
var current_directory := normalize_path(base_directory + "/" + directory)
if GdUnitTestSuiteScanner.exclude_scan_directories.has(current_directory):
continue
if match_test_directory(directory, test_directory):
prints("Collect tests at:", current_directory)
test_suite_paths.append(current_directory)
else:
scan_test_directorys(current_directory, test_directory, test_suite_paths)
return test_suite_paths
static func normalize_path(path :String) -> String:
return path.replace("///", "//")
static func match_test_directory(directory :String, test_directory: String) -> bool:
return directory == test_directory or test_directory.is_empty() or test_directory == "/" or test_directory == "res://"
func run_debug_mode():
_editor_interface.play_custom_scene("res://addons/gdUnit4/src/core/GdUnitRunner.tscn")
_is_running = true
func run_release_mode():
var arguments := Array()
if OS.is_stdout_verbose():
arguments.append("--verbose")
arguments.append("--no-window")
arguments.append("--path")
arguments.append(ProjectSettings.globalize_path("res://"))
arguments.append("res://addons/gdUnit4/src/core/GdUnitRunner.tscn")
_current_runner_process_id = OS.create_process(OS.get_executable_path(), arguments, false);
_is_running = true
func script_editor() -> ScriptEditor:
return _editor_interface.get_script_editor()
func active_base_editor() -> TextEdit:
return script_editor().get_current_editor().get_base_editor()
func active_script() -> Script:
return script_editor().get_current_script()
################################################################################
# signals handles
################################################################################
func _on_event(event :GdUnitEvent):
if event.type() == GdUnitEvent.STOP:
cmd_stop(_client_id)
func _on_stop_pressed():
cmd_stop(_client_id)
func _on_run_pressed(debug := false):
cmd_run(debug)
func _on_run_overall_pressed(_debug := false):
cmd_run_overall(true)
func _on_settings_changed(property :GdUnitProperty):
if SETTINGS_SHORTCUT_MAPPING.has(property.name()):
var shortcut :GdUnitShortcut.ShortCut = SETTINGS_SHORTCUT_MAPPING.get(property.name())
var input_event := create_shortcut_input_even(property.value())
prints("Shortcut changed: '%s' to '%s'" % [GdUnitShortcut.ShortCut.keys()[shortcut], input_event.as_text()])
register_shortcut(shortcut, input_event)
################################################################################
# Network stuff
################################################################################
func _on_client_connected(client_id :int) -> void:
_client_id = client_id
func _on_client_disconnected(client_id :int) -> void:
# only stops is not in debug mode running and the current client
if not _running_debug_mode and _client_id == client_id:
cmd_stop(client_id)
_client_id = -1

View file

@ -0,0 +1,58 @@
class_name GdUnitShortcut
extends RefCounted
enum ShortCut {
NONE,
RUN_TESTS_OVERALL,
RUN_TESTCASE,
RUN_TESTCASE_DEBUG,
RERUN_TESTS,
RERUN_TESTS_DEBUG,
STOP_TEST_RUN,
CREATE_TEST,
}
const CommandMapping = {
ShortCut.RUN_TESTS_OVERALL: GdUnitCommandHandler.CMD_RUN_OVERALL,
ShortCut.RUN_TESTCASE: GdUnitCommandHandler.CMD_RUN_TESTCASE,
ShortCut.RUN_TESTCASE_DEBUG: GdUnitCommandHandler.CMD_RUN_TESTCASE_DEBUG,
ShortCut.RERUN_TESTS: GdUnitCommandHandler.CMD_RERUN_TESTS,
ShortCut.RERUN_TESTS_DEBUG: GdUnitCommandHandler.CMD_RERUN_TESTS_DEBUG,
ShortCut.STOP_TEST_RUN: GdUnitCommandHandler.CMD_STOP_TEST_RUN,
ShortCut.CREATE_TEST: GdUnitCommandHandler.CMD_CREATE_TESTCASE,
}
const DEFAULTS_MACOS := {
ShortCut.NONE : [],
ShortCut.RUN_TESTCASE : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F5],
ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F6],
ShortCut.RUN_TESTS_OVERALL : [Key.KEY_META, Key.KEY_F7],
ShortCut.STOP_TEST_RUN : [Key.KEY_META, Key.KEY_F8],
ShortCut.RERUN_TESTS : [Key.KEY_META, Key.KEY_F5],
ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_META, Key.KEY_F6],
ShortCut.CREATE_TEST : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F10],
}
const DEFAULTS_WINDOWS := {
ShortCut.NONE : [],
ShortCut.RUN_TESTCASE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5],
ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_CTRL,Key.KEY_ALT, Key.KEY_F6],
ShortCut.RUN_TESTS_OVERALL : [Key.KEY_CTRL, Key.KEY_F7],
ShortCut.STOP_TEST_RUN : [Key.KEY_CTRL, Key.KEY_F8],
ShortCut.RERUN_TESTS : [Key.KEY_CTRL, Key.KEY_F5],
ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_CTRL, Key.KEY_F6],
ShortCut.CREATE_TEST : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F10],
}
static func default_keys(shortcut :ShortCut) -> PackedInt32Array:
match OS.get_name().to_lower():
'windows':
return DEFAULTS_WINDOWS[shortcut]
'macos':
return DEFAULTS_MACOS[shortcut]
_:
return DEFAULTS_WINDOWS[shortcut]

View file

@ -0,0 +1,36 @@
class_name GdUnitShortcutAction
extends RefCounted
func _init(p_type :GdUnitShortcut.ShortCut, p_shortcut :Shortcut, p_command :String):
assert(p_type != null, "missing parameter 'type'")
assert(p_shortcut != null, "missing parameter 'shortcut'")
assert(p_command != null, "missing parameter 'command'")
self.type = p_type
self.shortcut = p_shortcut
self.command = p_command
var type: GdUnitShortcut.ShortCut:
set(value):
type = value
get:
return type
var shortcut: Shortcut:
set(value):
shortcut = value
get:
return shortcut
var command: String:
set(value):
command = value
get:
return command
func _to_string() -> String:
return "GdUnitShortcutAction: %s (%s) -> %s" % [GdUnitShortcut.ShortCut.keys()[type], shortcut.get_as_text(), command]

View file

@ -0,0 +1,185 @@
class_name GdUnitEvent
extends Resource
const WARNINGS = "warnings"
const FAILED = "failed"
const ERRORS = "errors"
const SKIPPED = "skipped"
const ELAPSED_TIME = "elapsed_time"
const ORPHAN_NODES = "orphan_nodes"
const ERROR_COUNT = "error_count"
const FAILED_COUNT = "failed_count"
const SKIPPED_COUNT = "skipped_count"
enum {
INIT,
STOP,
TESTSUITE_BEFORE,
TESTSUITE_AFTER,
TESTCASE_BEFORE,
TESTCASE_AFTER,
}
var _event_type :int
var _resource_path :String
var _suite_name :String
var _test_name :String
var _total_count :int = 0
var _statistics := Dictionary()
var _reports :Array[GdUnitReport] = []
func suite_before(p_resource_path :String, p_suite_name :String, p_total_count :int) -> GdUnitEvent:
_event_type = TESTSUITE_BEFORE
_resource_path = p_resource_path
_suite_name = p_suite_name
_test_name = "before"
_total_count = p_total_count
return self
func suite_after(p_resource_path :String, p_suite_name :String, p_statistics :Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent:
_event_type = TESTSUITE_AFTER
_resource_path = p_resource_path
_suite_name = p_suite_name
_test_name = "after"
_statistics = p_statistics
_reports = p_reports
return self
func test_before(p_resource_path :String, p_suite_name :String, p_test_name :String) -> GdUnitEvent:
_event_type = TESTCASE_BEFORE
_resource_path = p_resource_path
_suite_name = p_suite_name
_test_name = p_test_name
return self
func test_after(p_resource_path :String, p_suite_name :String, p_test_name :String, p_statistics :Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent:
_event_type = TESTCASE_AFTER
_resource_path = p_resource_path
_suite_name = p_suite_name
_test_name = p_test_name
_statistics = p_statistics
_reports = p_reports
return self
func type() -> int:
return _event_type
func suite_name() -> String:
return _suite_name
func test_name() -> String:
return _test_name
func elapsed_time() -> int:
return _statistics.get(ELAPSED_TIME, 0)
func orphan_nodes() -> int:
return _statistics.get(ORPHAN_NODES, 0)
func statistic(p_type :String) -> int:
return _statistics.get(p_type, 0)
func total_count() -> int:
return _total_count
func success_count() -> int:
return total_count() - error_count() - failed_count() - skipped_count()
func error_count() -> int:
return _statistics.get(ERROR_COUNT, 0)
func failed_count() -> int:
return _statistics.get(FAILED_COUNT, 0)
func skipped_count() -> int:
return _statistics.get(SKIPPED_COUNT, 0)
func resource_path() -> String:
return _resource_path
func is_success() -> bool:
return not is_warning() and not is_failed() and not is_error() and not is_skipped()
func is_warning() -> bool:
return _statistics.get(WARNINGS, false)
func is_failed() -> bool:
return _statistics.get(FAILED, false)
func is_error() -> bool:
return _statistics.get(ERRORS, false)
func is_skipped() -> bool:
return _statistics.get(SKIPPED, false)
func reports() -> Array:
return _reports
func _to_string() -> String:
return "Event: %s %s:%s, %s, %s" % [_event_type, _suite_name, _test_name, _statistics, _reports]
func serialize() -> Dictionary:
var serialized := {
"type" : _event_type,
"resource_path": _resource_path,
"suite_name" : _suite_name,
"test_name" : _test_name,
"total_count" : _total_count,
"statistics" : _statistics
}
serialized["reports"] = _serialize_TestReports()
return serialized
func deserialize(serialized :Dictionary) -> GdUnitEvent:
_event_type = serialized.get("type", null)
_resource_path = serialized.get("resource_path", null)
_suite_name = serialized.get("suite_name", null)
_test_name = serialized.get("test_name", "unknown")
_total_count = serialized.get("total_count", 0)
_statistics = serialized.get("statistics", Dictionary())
if serialized.has("reports"):
# needs this workaround to copy typed values in the array
var reports :Array[Dictionary] = []
reports.append_array(serialized.get("reports"))
_reports = _deserialize_reports(reports)
return self
func _serialize_TestReports() -> Array[Dictionary]:
var serialized_reports :Array[Dictionary] = []
for report in _reports:
serialized_reports.append(report.serialize())
return serialized_reports
func _deserialize_reports(p_reports :Array[Dictionary]) -> Array[GdUnitReport]:
var deserialized_reports :Array[GdUnitReport] = []
for report in p_reports:
var test_report := GdUnitReport.new().deserialize(report)
deserialized_reports.append(test_report)
return deserialized_reports

View file

@ -0,0 +1,19 @@
class_name GdUnitInit
extends GdUnitEvent
var _total_testsuites :int
func _init(p_total_testsuites :int, p_total_count :int) -> void:
_event_type = INIT
_total_testsuites = p_total_testsuites
_total_count = p_total_count
func total_test_suites() -> int:
return _total_testsuites
func total_tests() -> int:
return _total_count

View file

@ -0,0 +1,6 @@
class_name GdUnitStop
extends GdUnitEvent
func _init() -> void:
_event_type = STOP

View file

@ -0,0 +1,192 @@
## The execution context
## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor
class_name GdUnitExecutionContext
var _parent_context :GdUnitExecutionContext
var _sub_context :Array[GdUnitExecutionContext] = []
var _orphan_monitor :GdUnitOrphanNodesMonitor
var _memory_observer :GdUnitMemoryObserver
var _report_collector :GdUnitTestReportCollector
var _timer :LocalTime
var _test_case_name: StringName
var _name :String
var error_monitor : GodotGdErrorMonitor = null:
set (value):
error_monitor = value
get:
if _parent_context != null:
return _parent_context.error_monitor
return error_monitor
var test_suite : GdUnitTestSuite = null:
set (value):
test_suite = value
get:
if _parent_context != null:
return _parent_context.test_suite
return test_suite
var test_case : _TestCase = null:
get:
if _test_case_name.is_empty():
return null
return test_suite.find_child(_test_case_name, false, false)
func _init(name :String, parent_context :GdUnitExecutionContext = null) -> void:
_name = name
_parent_context = parent_context
_timer = LocalTime.now()
_orphan_monitor = GdUnitOrphanNodesMonitor.new(name)
_orphan_monitor.start()
_memory_observer = GdUnitMemoryObserver.new()
error_monitor = GodotGdErrorMonitor.new()
_report_collector = GdUnitTestReportCollector.new(get_instance_id())
if parent_context != null:
parent_context._sub_context.append(self)
func dispose() -> void:
_timer = null
_orphan_monitor = null
_report_collector = null
_memory_observer = null
_parent_context = null
test_suite = null
test_case = null
for context in _sub_context:
context.dispose()
_sub_context.clear()
func set_active() -> void:
test_suite.__execution_context = self
GdUnitThreadManager.get_current_context().set_execution_context(self)
static func of_test_suite(test_suite_ :GdUnitTestSuite) -> GdUnitExecutionContext:
assert(test_suite_, "test_suite is null")
var context := GdUnitExecutionContext.new(test_suite_.get_name())
context.test_suite = test_suite_
context.set_active()
return context
static func of_test_case(pe :GdUnitExecutionContext, test_case_name :StringName) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(test_case_name, pe)
context._test_case_name = test_case_name
context.set_active()
return context
static func of(pe :GdUnitExecutionContext) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(pe._test_case_name, pe)
context._test_case_name = pe._test_case_name
context.set_active()
return context
func test_failed() -> bool:
return has_failures() or has_errors()
func error_monitor_start() -> void:
error_monitor.start()
func error_monitor_stop() -> void:
await error_monitor.scan()
for error_report in error_monitor.to_reports():
if error_report.is_error():
_report_collector._reports.append(error_report)
func orphan_monitor_start() -> void:
_orphan_monitor.start()
func orphan_monitor_stop() -> void:
_orphan_monitor.stop()
func reports() -> Array[GdUnitReport]:
return _report_collector.reports()
func build_report_statistics(orphans :int, recursive := true) -> Dictionary:
return {
GdUnitEvent.ORPHAN_NODES: orphans,
GdUnitEvent.ELAPSED_TIME: _timer.elapsed_since_ms(),
GdUnitEvent.FAILED: has_failures(),
GdUnitEvent.ERRORS: has_errors(),
GdUnitEvent.WARNINGS: has_warnings(),
GdUnitEvent.SKIPPED: has_skipped(),
GdUnitEvent.FAILED_COUNT: count_failures(recursive),
GdUnitEvent.ERROR_COUNT: count_errors(recursive),
GdUnitEvent.SKIPPED_COUNT: count_skipped(recursive)
}
func has_failures() -> bool:
return _sub_context.any(func(c): return c.has_failures()) or _report_collector.has_failures()
func has_errors() -> bool:
return _sub_context.any(func(c): return c.has_errors()) or _report_collector.has_errors()
func has_warnings() -> bool:
return _sub_context.any(func(c): return c.has_warnings()) or _report_collector.has_warnings()
func has_skipped() -> bool:
return _sub_context.any(func(c): return c.has_skipped()) or _report_collector.has_skipped()
func count_failures(recursive :bool) -> int:
if not recursive:
return _report_collector.count_failures()
return _sub_context\
.map(func(c): return c.count_failures(recursive))\
.reduce(sum, _report_collector.count_failures())
func count_errors(recursive :bool) -> int:
if not recursive:
return _report_collector.count_errors()
return _sub_context\
.map(func(c): return c.count_errors(recursive))\
.reduce(sum, _report_collector.count_errors())
func count_skipped(recursive :bool) -> int:
if not recursive:
return _report_collector.count_skipped()
return _sub_context\
.map(func(c): return c.count_skipped(recursive))\
.reduce(sum, _report_collector.count_skipped())
func count_orphans() -> int:
var orphans := 0
for c in _sub_context:
orphans += c._orphan_monitor.orphan_nodes()
return _orphan_monitor.orphan_nodes() - orphans
func sum(accum :int, number :int) -> int:
return accum + number
func register_auto_free(obj :Variant) -> Variant:
return _memory_observer.register_auto_free(obj)
## Runs the gdunit garbage collector to free registered object
func gc() -> void:
await _memory_observer.gc()
orphan_monitor_stop()

View file

@ -0,0 +1,131 @@
## The memory watcher for objects that have been registered and are released when 'gc' is called.
class_name GdUnitMemoryObserver
extends RefCounted
const TAG_OBSERVE_INSTANCE := "GdUnit4_observe_instance_"
const TAG_AUTO_FREE = "GdUnit4_marked_auto_free"
const GdUnitTools = preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
var _store :Array[Variant] = []
# enable for debugging purposes
var _is_stdout_verbose := false
const _show_debug := false
## Registration of an instance to be released when an execution phase is completed
func register_auto_free(obj) -> Variant:
if not is_instance_valid(obj):
return obj
# do not register on GDScriptNativeClass
if typeof(obj) == TYPE_OBJECT and (obj as Object).is_class("GDScriptNativeClass") :
return obj
#if obj is GDScript or obj is ScriptExtension:
# return obj
if obj is MainLoop:
push_error("GdUnit4: Avoid to add mainloop to auto_free queue %s" % obj)
return
if _is_stdout_verbose:
print_verbose("GdUnit4:gc():register auto_free(%s)" % obj)
# only register pure objects
if obj is GdUnitSceneRunner:
_store.push_back(obj)
else:
_store.append(obj)
_tag_object(obj)
return obj
# to disable instance guard when run into issues.
static func _is_instance_guard_enabled() -> bool:
return false
static func debug_observe(name :String, obj :Object, indent :int = 0) -> void:
if not _show_debug:
return
var script :GDScript= obj if obj is GDScript else obj.get_script()
if script:
var base_script :GDScript = script.get_base_script()
prints("".lpad(indent, " "), name, obj, obj.get_class(), "reference_count:", obj.get_reference_count() if obj is RefCounted else 0, "script:", script, script.resource_path)
if base_script:
debug_observe("+", base_script, indent+1)
else:
prints(name, obj, obj.get_class(), obj.get_name())
static func guard_instance(obj :Object) -> Object:
if not _is_instance_guard_enabled():
return
var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
if Engine.has_meta(tag):
return
debug_observe("Gard on instance", obj)
Engine.set_meta(tag, obj)
return obj
static func unguard_instance(obj :Object, verbose := true) -> void:
if not _is_instance_guard_enabled():
return
var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
if verbose:
debug_observe("unguard instance", obj)
if Engine.has_meta(tag):
Engine.remove_meta(tag)
static func gc_guarded_instance(name :String, instance :Object) -> void:
if not _is_instance_guard_enabled():
return
await Engine.get_main_loop().process_frame
unguard_instance(instance, false)
if is_instance_valid(instance) and instance is RefCounted:
# finally do this very hacky stuff
# we need to manually unreferece to avoid leaked scripts
# but still leaked GDScriptFunctionState exists
#var script :GDScript = instance.get_script()
#if script:
# var base_script :GDScript = script.get_base_script()
# if base_script:
# base_script.unreference()
debug_observe(name, instance)
instance.unreference()
await Engine.get_main_loop().process_frame
static func gc_on_guarded_instances() -> void:
if not _is_instance_guard_enabled():
return
for tag in Engine.get_meta_list():
if tag.begins_with(TAG_OBSERVE_INSTANCE):
var instance = Engine.get_meta(tag)
await gc_guarded_instance("Leaked instance detected:", instance)
await GdUnitTools.free_instance(instance, false)
# store the object into global store aswell to be verified by 'is_marked_auto_free'
func _tag_object(obj :Variant) -> void:
var tagged_object := Engine.get_meta(TAG_AUTO_FREE, []) as Array
tagged_object.append(obj)
Engine.set_meta(TAG_AUTO_FREE, tagged_object)
## Runs over all registered objects and releases them
func gc() -> void:
if _store.is_empty():
return
# give engine time to free objects to process objects marked by queue_free()
await Engine.get_main_loop().process_frame
if _is_stdout_verbose:
print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size())
var tagged_objects := Engine.get_meta(TAG_AUTO_FREE, []) as Array
while not _store.is_empty():
var value :Variant = _store.pop_front()
tagged_objects.erase(value)
await GdUnitTools.free_instance(value, _is_stdout_verbose)
## Checks whether the specified object is registered for automatic release
static func is_marked_auto_free(obj) -> bool:
return Engine.get_meta(TAG_AUTO_FREE, []).has(obj)

View file

@ -0,0 +1,70 @@
# Collects all reports seperated as warnings, failures and errors
class_name GdUnitTestReportCollector
extends RefCounted
var _execution_context_id :int
var _reports :Array[GdUnitReport] = []
static func __filter_is_error(report :GdUnitReport) -> bool:
return report.is_error()
static func __filter_is_failure(report :GdUnitReport) -> bool:
return report.is_failure()
static func __filter_is_warning(report :GdUnitReport) -> bool:
return report.is_warning()
static func __filter_is_skipped(report :GdUnitReport) -> bool:
return report.is_skipped()
func _init(execution_context_id :int):
_execution_context_id = execution_context_id
GdUnitSignals.instance().gdunit_report.connect(on_reports)
func count_failures() -> int:
return _reports.filter(__filter_is_failure).size()
func count_errors() -> int:
return _reports.filter(__filter_is_error).size()
func count_warnings() -> int:
return _reports.filter(__filter_is_warning).size()
func count_skipped() -> int:
return _reports.filter(__filter_is_skipped).size()
func has_failures() -> bool:
return _reports.any(__filter_is_failure)
func has_errors() -> bool:
return _reports.any(__filter_is_error)
func has_warnings() -> bool:
return _reports.any(__filter_is_warning)
func has_skipped() -> bool:
return _reports.any(__filter_is_skipped)
func reports() -> Array[GdUnitReport]:
return _reports
# Consumes reports emitted by tests
func on_reports(execution_context_id :int, report :GdUnitReport) -> void:
if execution_context_id == _execution_context_id:
_reports.append(report)

View file

@ -0,0 +1,26 @@
## The executor to run a test-suite
class_name GdUnitTestSuiteExecutor
# preload all asserts here
@warning_ignore("unused_private_class_variable")
var _assertions := GdUnitAssertions.new()
var _executeStage :IGdUnitExecutionStage = GdUnitTestSuiteExecutionStage.new()
func _init(debug_mode :bool = false) -> void:
_executeStage.set_debug_mode(debug_mode)
func execute(test_suite :GdUnitTestSuite) -> void:
var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans()
if not orphan_detection_enabled:
prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.")
Engine.get_main_loop().root.call_deferred("add_child", test_suite)
await Engine.get_main_loop().process_frame
await _executeStage.execute(GdUnitExecutionContext.of_test_suite(test_suite))
func fail_fast(enabled :bool) -> void:
_executeStage.fail_fast(enabled)

View file

@ -0,0 +1,100 @@
## The test case shutdown hook implementation.[br]
## It executes the 'test_after()' block from the test-suite.
class_name GdUnitTestCaseAfterStage
extends IGdUnitExecutionStage
var _test_name :StringName = ""
var _call_stage :bool
func _init(call_stage := true):
_call_stage = call_stage
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
if _call_stage:
@warning_ignore("redundant_await")
await test_suite.after_test()
# unreference last used assert form the test to prevent memory leaks
GdUnitThreadManager.get_current_context().set_assert(null)
await context.gc()
await context.error_monitor_stop()
if context.test_case.is_skipped():
fire_test_skipped(context)
else:
fire_test_ended(context)
if is_instance_valid(context.test_case):
context.test_case.dispose()
func set_test_name(test_name :StringName):
_test_name = test_name
func fire_test_ended(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var test_name := context._test_case_name if _test_name.is_empty() else _test_name
var reports := collect_reports(context)
var orphans := collect_orphans(context, reports)
fire_event(GdUnitEvent.new()\
.test_after(test_suite.get_script().resource_path, test_suite.get_name(), test_name, context.build_report_statistics(orphans), reports))
func collect_orphans(context :GdUnitExecutionContext, reports :Array[GdUnitReport]) -> int:
var orphans := 0
if not context._sub_context.is_empty():
orphans += add_orphan_report_test(context._sub_context[0], reports)
orphans += add_orphan_report_teststage(context, reports)
return orphans
func collect_reports(context :GdUnitExecutionContext) -> Array[GdUnitReport]:
var reports := context.reports()
var test_case := context.test_case
if test_case.is_interupted() and not test_case.is_expect_interupted() and test_case.report() != null:
reports.push_back(test_case.report())
# we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended`
if not context._sub_context.is_empty():
reports.append_array(context._sub_context[0].reports())
# needs finally to clean the test reports to avoid counting twice
context._sub_context[0].reports().clear()
return reports
func add_orphan_report_test(context :GdUnitExecutionContext, reports :Array[GdUnitReport]) -> int:
var orphans := context.count_orphans()
if orphans > 0:
reports.push_front(GdUnitReport.new()\
.create(GdUnitReport.WARN, context.test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans)))
return orphans
func add_orphan_report_teststage(context :GdUnitExecutionContext, reports :Array[GdUnitReport]) -> int:
var orphans := context.count_orphans()
if orphans > 0:
reports.push_front(GdUnitReport.new()\
.create(GdUnitReport.WARN, context.test_case.line_number(), GdAssertMessages.orphan_detected_on_test_setup(orphans)))
return orphans
func fire_test_skipped(context :GdUnitExecutionContext):
var test_suite := context.test_suite
var test_case := context.test_case
var test_case_name := context._test_case_name if _test_name.is_empty() else _test_name
var statistics = {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED: true,
GdUnitEvent.SKIPPED_COUNT: 1,
}
var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info()))
fire_event(GdUnitEvent.new()\
.test_after(test_suite.get_script().resource_path, test_suite.get_name(), test_case_name, statistics, [report]))

View file

@ -0,0 +1,29 @@
## The test case startup hook implementation.[br]
## It executes the 'test_before()' block from the test-suite.
class_name GdUnitTestCaseBeforeStage
extends IGdUnitExecutionStage
var _test_name :StringName = ""
var _call_stage :bool
func _init(call_stage := true):
_call_stage = call_stage
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var test_case_name := context._test_case_name if _test_name.is_empty() else _test_name
fire_event(GdUnitEvent.new()\
.test_before(test_suite.get_script().resource_path, test_suite.get_name(), test_case_name))
if _call_stage:
@warning_ignore("redundant_await")
await test_suite.before_test()
context.error_monitor_start()
func set_test_name(test_name :StringName):
_test_name = test_name

View file

@ -0,0 +1,31 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseExecutionStage
extends IGdUnitExecutionStage
var _stage_single_test :IGdUnitExecutionStage = GdUnitTestCaseSingleExecutionStage.new()
var _stage_fuzzer_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionStage.new()
var _stage_parameterized_test :IGdUnitExecutionStage= GdUnitTestCaseParameterizedExecutionStage.new()
## Executes the test case 'test_<name>()'.[br]
## It executes synchronized following stages[br]
## -> test_before() [br]
## -> test_case() [br]
## -> test_after() [br]
@warning_ignore("redundant_await")
func _execute(context :GdUnitExecutionContext) -> void:
var test_case := context.test_case
if test_case.is_parameterized():
await _stage_parameterized_test.execute(context)
elif test_case.is_fuzzed():
await _stage_fuzzer_test.execute(context)
else:
await _stage_single_test.execute(context)
func set_debug_mode(debug_mode :bool = false):
super.set_debug_mode(debug_mode)
_stage_single_test.set_debug_mode(debug_mode)
_stage_fuzzer_test.set_debug_mode(debug_mode)
_stage_parameterized_test.set_debug_mode(debug_mode)

View file

@ -0,0 +1,28 @@
## The test suite shutdown hook implementation.[br]
## It executes the 'after()' block from the test-suite.
class_name GdUnitTestSuiteAfterStage
extends IGdUnitExecutionStage
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
@warning_ignore("redundant_await")
await test_suite.after()
# unreference last used assert form the test to prevent memory leaks
GdUnitThreadManager.get_current_context().set_assert(null)
await context.gc()
var reports := context.reports()
var orphans := context.count_orphans()
if orphans > 0:
reports.push_front(GdUnitReport.new() \
.create(GdUnitReport.WARN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphans)))
fire_event(GdUnitEvent.new().suite_after(test_suite.get_script().resource_path, test_suite.get_name(), context.build_report_statistics(orphans, false), reports))
GdUnitFileAccess.clear_tmp()
# Guard that checks if all doubled (spy/mock) objects are released
GdUnitClassDoubler.check_leaked_instances()

View file

@ -0,0 +1,14 @@
## The test suite startup hook implementation.[br]
## It executes the 'before()' block from the test-suite.
class_name GdUnitTestSuiteBeforeStage
extends IGdUnitExecutionStage
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
fire_event(GdUnitEvent.new()\
.suite_before(test_suite.get_script().resource_path, test_suite.get_name(), test_suite.get_child_count()))
@warning_ignore("redundant_await")
await test_suite.before()

View file

@ -0,0 +1,114 @@
## The test suite main execution stage.[br]
class_name GdUnitTestSuiteExecutionStage
extends IGdUnitExecutionStage
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
var _stage_before :IGdUnitExecutionStage = GdUnitTestSuiteBeforeStage.new()
var _stage_after :IGdUnitExecutionStage = GdUnitTestSuiteAfterStage.new()
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseExecutionStage.new()
var _fail_fast := false
## Executes all tests of an test suite.[br]
## It executes synchronized following stages[br]
## -> before() [br]
## -> run all test cases [br]
## -> after() [br]
func _execute(context :GdUnitExecutionContext) -> void:
if context.test_suite.__is_skipped:
await fire_test_suite_skipped(context)
else:
GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter)
await _stage_before.execute(context)
for test_case_index in context.test_suite.get_child_count():
# iterate only over test cases
var test_case := context.test_suite.get_child(test_case_index) as _TestCase
if not is_instance_valid(test_case):
continue
context.test_suite.set_active_test_case(test_case.get_name())
await _stage_test.execute(GdUnitExecutionContext.of_test_case(context, test_case.get_name()))
# stop on first error or if fail fast is enabled
if _fail_fast and context.test_failed():
break
if test_case.is_interupted():
# it needs to go this hard way to kill the outstanding awaits of a test case when the test timed out
# we delete the current test suite where is execute the current test case to kill the function state
# and replace it by a clone without function state
context.test_suite = await clone_test_suite(context.test_suite)
await _stage_after.execute(context)
GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter)
await Engine.get_main_loop().process_frame
context.test_suite.free()
context.dispose()
# clones a test suite and moves the test cases to new instance
func clone_test_suite(test_suite :GdUnitTestSuite) -> GdUnitTestSuite:
await Engine.get_main_loop().process_frame
dispose_timers(test_suite)
await GdUnitMemoryObserver.gc_guarded_instance("Manually free on awaiter", test_suite.__awaiter)
var parent := test_suite.get_parent()
var _test_suite = GdUnitTestSuite.new()
parent.remove_child(test_suite)
copy_properties(test_suite, _test_suite)
for child in test_suite.get_children():
test_suite.remove_child(child)
_test_suite.add_child(child)
parent.add_child(_test_suite)
GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter)
# finally free current test suite instance
test_suite.free()
await Engine.get_main_loop().process_frame
return _test_suite
func dispose_timers(test_suite :GdUnitTestSuite):
GdUnitTools.release_timers()
for child in test_suite.get_children():
if child is Timer:
child.stop()
test_suite.remove_child(child)
child.free()
func copy_properties(source :Object, target :Object):
if not source is _TestCase and not source is GdUnitTestSuite:
return
for property in source.get_property_list():
var property_name = property["name"]
if property_name == "__awaiter":
continue
target.set(property_name, source.get(property_name))
func fire_test_suite_skipped(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var skip_count := test_suite.get_child_count()
fire_event(GdUnitEvent.new()\
.suite_before(test_suite.get_script().resource_path, test_suite.get_name(), skip_count))
var statistics = {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED_COUNT: skip_count,
GdUnitEvent.SKIPPED: true
}
var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, -1, GdAssertMessages.test_suite_skipped(test_suite.__skip_reason, skip_count))
fire_event(GdUnitEvent.new().suite_after(test_suite.get_script().resource_path, test_suite.get_name(), statistics, [report]))
await Engine.get_main_loop().process_frame
func set_debug_mode(debug_mode :bool = false):
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)
func fail_fast(enabled :bool) -> void:
_fail_fast = enabled

View file

@ -0,0 +1,39 @@
## The interface of execution stage.[br]
## An execution stage is defined as an encapsulated task that can execute 1-n substages covered by its own execution context.[br]
## Execution stage are always called synchronously.
class_name IGdUnitExecutionStage
extends RefCounted
var _debug_mode := false
## Executes synchronized the implemented stage in its own execution context.[br]
## example:[br]
## [codeblock]
## # waits for 100ms
## await MyExecutionStage.new().execute(<GdUnitExecutionContext>)
## [/codeblock][br]
func execute(context :GdUnitExecutionContext) -> void:
context.set_active()
@warning_ignore("redundant_await")
await _execute(context)
## Sends the event to registered listeners
func fire_event(event :GdUnitEvent) -> void:
if _debug_mode:
GdUnitSignals.instance().gdunit_event_debug.emit(event)
else:
GdUnitSignals.instance().gdunit_event.emit(event)
## Internal testing stuff.[br]
## Sets the executor into debug mode to emit `GdUnitEvent` via signal `gdunit_event_debug`
func set_debug_mode(debug_mode :bool) -> void:
_debug_mode = debug_mode
## The execution phase to be carried out.
func _execute(_context :GdUnitExecutionContext) -> void:
@warning_ignore("assert_always_false")
assert(false, "The execution stage is not implemented")

View file

@ -0,0 +1,21 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseFuzzedExecutionStage
extends IGdUnitExecutionStage
var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false)
var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false)
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedTestStage.new()
func _execute(context :GdUnitExecutionContext) -> void:
await _stage_before.execute(context)
if not context.test_case.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(context))
await _stage_after.execute(context)
func set_debug_mode(debug_mode :bool = false):
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)

View file

@ -0,0 +1,53 @@
## The fuzzed test case execution stage.[br]
class_name GdUnitTestCaseFuzzedTestStage
extends IGdUnitExecutionStage
var _expression_runner := GdUnitExpressionRunner.new()
## Executes a test case with given fuzzers 'test_<name>(<fuzzer>)' iterative.[br]
## It executes synchronized following stages[br]
## -> test_case() [br]
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var test_case := context.test_case
var fuzzers := create_fuzzers(test_suite, test_case)
# guard on fuzzers
for fuzzer in fuzzers:
GdUnitMemoryObserver.guard_instance(fuzzer)
for iteration in test_case.iterations():
@warning_ignore("redundant_await")
await test_suite.before_test()
await test_case.execute(fuzzers, iteration)
@warning_ignore("redundant_await")
await test_suite.after_test()
if test_case.is_interupted():
break
# interrupt at first failure
var reports := context.reports()
if not reports.is_empty():
var report :GdUnitReport = reports.pop_front()
reports.append(GdUnitReport.new() \
.create(GdUnitReport.FAILURE, report.line_number(), GdAssertMessages.fuzzer_interuped(iteration, report.message())))
break
await context.gc()
# unguard on fuzzers
if not test_case.is_interupted():
for fuzzer in fuzzers:
GdUnitMemoryObserver.unguard_instance(fuzzer)
func create_fuzzers(test_suite :GdUnitTestSuite, test_case :_TestCase) -> Array[Fuzzer]:
if not test_case.is_fuzzed():
return Array()
test_case.generate_seed()
var fuzzers :Array[Fuzzer] = []
for fuzzer_arg in test_case.fuzzer_arguments():
var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script(), fuzzer_arg.value_as_string())
fuzzer._iteration_index = 0
fuzzer._iteration_limit = test_case.iterations()
fuzzers.append(fuzzer)
return fuzzers

View file

@ -0,0 +1,22 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseParameterizedExecutionStage
extends IGdUnitExecutionStage
var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false)
var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false)
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseParamaterizedTestStage.new()
func _execute(context :GdUnitExecutionContext) -> void:
await _stage_before.execute(context)
if not context.test_case.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(context))
await _stage_after.execute(context)
func set_debug_mode(debug_mode :bool = false):
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)

View file

@ -0,0 +1,76 @@
## The parameterized test case execution stage.[br]
class_name GdUnitTestCaseParamaterizedTestStage
extends IGdUnitExecutionStage
var _stage_before: IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new()
var _stage_after: IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new()
## Executes a parameterized test case.[br]
## It executes synchronized following stages[br]
## -> test_case( <test_parameters> ) [br]
func _execute(context: GdUnitExecutionContext) -> void:
var test_case := context.test_case
var test_parameter_index := test_case.test_parameter_index()
var is_fail := false
var is_error := false
var failing_index := 0
var parameter_set_resolver := test_case.parameter_set_resolver()
var test_names := parameter_set_resolver.build_test_case_names(test_case)
# if all parameter sets has static values we can preload and reuse it for better performance
var parameter_sets :Array = []
if parameter_set_resolver.is_parameter_sets_static():
parameter_sets = parameter_set_resolver.load_parameter_sets(test_case, true)
for parameter_set_index in test_names.size():
# is test_parameter_index is set, we run this parameterized test only
if test_parameter_index != -1 and test_parameter_index != parameter_set_index:
continue
var current_test_case_name = test_names[parameter_set_index]
_stage_before.set_test_name(current_test_case_name)
_stage_after.set_test_name(current_test_case_name)
var test_context := GdUnitExecutionContext.of(context)
await _stage_before.execute(test_context)
var current_parameter_set :Array
if parameter_set_resolver.is_parameter_set_static(parameter_set_index):
current_parameter_set = parameter_sets[parameter_set_index]
else:
current_parameter_set = _load_parameter_set(context, parameter_set_index)
if not test_case.is_interupted():
await test_case.execute_paramaterized(current_parameter_set)
await _stage_after.execute(test_context)
# we need to clean up the reports here so they are not reported twice
is_fail = is_fail or test_context.count_failures(false) > 0
is_error = is_error or test_context.count_errors(false) > 0
failing_index = parameter_set_index - 1
test_context.reports().clear()
if test_case.is_interupted():
break
# add report to parent execution context if failed or an error is found
if is_fail:
context.reports().append(GdUnitReport.new().create(GdUnitReport.FAILURE, test_case.line_number(), "Test failed at parameterized index %d." % failing_index))
if is_error:
context.reports().append(GdUnitReport.new().create(GdUnitReport.ABORT, test_case.line_number(), "Test aborted at parameterized index %d." % failing_index))
await context.gc()
func _load_parameter_set(context: GdUnitExecutionContext, parameter_set_index: int) -> Array:
var test_case := context.test_case
var test_suite := context.test_suite
# we need to exchange temporary for parameter resolving the execution context
# this is necessary because of possible usage of `auto_free` and needs to run in the parent execution context
var save_execution_context: GdUnitExecutionContext = test_suite.__execution_context
context.set_active()
var parameters := test_case.load_parameter_sets()
# restore the original execution context and restart the orphan monitor to get new instances into account
save_execution_context.set_active()
save_execution_context.orphan_monitor_start()
return parameters[parameter_set_index]
func set_debug_mode(debug_mode: bool=false):
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)

View file

@ -0,0 +1,22 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseSingleExecutionStage
extends IGdUnitExecutionStage
var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new()
var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new()
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseSingleTestStage.new()
func _execute(context :GdUnitExecutionContext) -> void:
await _stage_before.execute(context)
if not context.test_case.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(context))
await _stage_after.execute(context)
func set_debug_mode(debug_mode :bool = false):
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)

View file

@ -0,0 +1,11 @@
## The single test case execution stage.[br]
class_name GdUnitTestCaseSingleTestStage
extends IGdUnitExecutionStage
## Executes a single test case 'test_<name>()'.[br]
## It executes synchronized following stages[br]
## -> test_case() [br]
func _execute(context :GdUnitExecutionContext) -> void:
await context.test_case.execute()
await context.gc()

View file

@ -0,0 +1,34 @@
class_name GdClassDescriptor
extends RefCounted
var _name :String
var _parent = null
var _is_inner_class :bool
var _functions
func _init(p_name :String, p_is_inner_class :bool, p_functions :Array):
_name = p_name
_is_inner_class = p_is_inner_class
_functions = p_functions
func set_parent_clazz(p_parent :GdClassDescriptor):
_parent = p_parent
func name() -> String:
return _name
func parent() -> GdClassDescriptor:
return _parent
func is_inner_class() -> bool:
return _is_inner_class
func functions() -> Array:
return _functions

View file

@ -0,0 +1,255 @@
# holds all decodings for default values
class_name GdDefaultValueDecoder
extends GdUnitSingleton
@warning_ignore("unused_parameter")
var _decoders = {
TYPE_NIL: func(value): return "null",
TYPE_STRING: func(value): return '"%s"' % value,
TYPE_STRING_NAME: _on_type_StringName,
TYPE_BOOL: func(value): return str(value).to_lower(),
TYPE_FLOAT: func(value): return '%f' % value,
TYPE_COLOR: _on_type_Color,
TYPE_ARRAY: _on_type_Array.bind(TYPE_ARRAY),
TYPE_PACKED_BYTE_ARRAY: _on_type_Array.bind(TYPE_PACKED_BYTE_ARRAY),
TYPE_PACKED_STRING_ARRAY: _on_type_Array.bind(TYPE_PACKED_STRING_ARRAY),
TYPE_PACKED_FLOAT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT32_ARRAY),
TYPE_PACKED_FLOAT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT64_ARRAY),
TYPE_PACKED_INT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT32_ARRAY),
TYPE_PACKED_INT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT64_ARRAY),
TYPE_PACKED_COLOR_ARRAY: _on_type_Array.bind(TYPE_PACKED_COLOR_ARRAY),
TYPE_PACKED_VECTOR2_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR2_ARRAY),
TYPE_PACKED_VECTOR3_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR3_ARRAY),
TYPE_DICTIONARY: _on_type_Dictionary,
TYPE_RID: _on_type_RID,
TYPE_NODE_PATH: _on_type_NodePath,
TYPE_VECTOR2: _on_type_Vector.bind(TYPE_VECTOR2),
TYPE_VECTOR2I: _on_type_Vector.bind(TYPE_VECTOR2I),
TYPE_VECTOR3: _on_type_Vector.bind(TYPE_VECTOR3),
TYPE_VECTOR3I: _on_type_Vector.bind(TYPE_VECTOR3I),
TYPE_VECTOR4: _on_type_Vector.bind(TYPE_VECTOR4),
TYPE_VECTOR4I: _on_type_Vector.bind(TYPE_VECTOR4I),
TYPE_RECT2: _on_type_Rect2,
TYPE_RECT2I: _on_type_Rect2i,
TYPE_PLANE: _on_type_Plane,
TYPE_QUATERNION: _on_type_Quaternion,
TYPE_AABB: _on_type_AABB,
TYPE_BASIS: _on_type_Basis,
TYPE_CALLABLE: _on_type_Callable,
TYPE_SIGNAL: _on_type_Signal,
TYPE_TRANSFORM2D: _on_type_Transform2D,
TYPE_TRANSFORM3D: _on_type_Transform3D,
TYPE_PROJECTION: _on_type_Projection,
TYPE_OBJECT: _on_type_Object
}
static func _regex(pattern :String) -> RegEx:
var regex := RegEx.new()
var err = regex.compile(pattern)
if err != OK:
push_error("error '%s' checked pattern '%s'" % [err, pattern])
return null
return regex
func get_decoder(type :int) -> Callable:
return _decoders.get(type, func(value): return '%s' % value)
func _on_type_StringName(value :StringName) -> String:
if value.is_empty():
return 'StringName()'
return 'StringName("%s")' % value
func _on_type_Object(value :Object, type :int) -> String:
return str(value)
func _on_type_Color(color :Color) -> String:
if color == Color.BLACK:
return "Color()"
return "Color%s" % color
func _on_type_NodePath(path :NodePath) -> String:
if path.is_empty():
return 'NodePath()'
return 'NodePath("%s")' % path
func _on_type_Callable(cb :Callable) -> String:
return 'Callable()'
func _on_type_Signal(s :Signal) -> String:
return 'Signal()'
func _on_type_Dictionary(dict :Dictionary) -> String:
if dict.is_empty():
return '{}'
return str(dict)
func _on_type_Array(value, type :int) -> String:
match type:
TYPE_ARRAY:
return str(value)
TYPE_PACKED_COLOR_ARRAY:
var colors := PackedStringArray()
for color in value as PackedColorArray:
colors.append(_on_type_Color(color))
if colors.is_empty():
return "PackedColorArray()"
return "PackedColorArray([%s])" % ", ".join(colors)
TYPE_PACKED_VECTOR2_ARRAY:
var vectors := PackedStringArray()
for vector in value as PackedVector2Array:
vectors.append(_on_type_Vector(vector, TYPE_VECTOR2))
if vectors.is_empty():
return "PackedVector2Array()"
return "PackedVector2Array([%s])" % ", ".join(vectors)
TYPE_PACKED_VECTOR3_ARRAY:
var vectors := PackedStringArray()
for vector in value as PackedVector3Array:
vectors.append(_on_type_Vector(vector, TYPE_VECTOR3))
if vectors.is_empty():
return "PackedVector3Array()"
return "PackedVector3Array([%s])" % ", ".join(vectors)
TYPE_PACKED_STRING_ARRAY:
var values := PackedStringArray()
for v in value as PackedStringArray:
values.append('"%s"' % v)
if values.is_empty():
return "PackedStringArray()"
return "PackedStringArray([%s])" % ", ".join(values)
TYPE_PACKED_BYTE_ARRAY,\
TYPE_PACKED_FLOAT32_ARRAY,\
TYPE_PACKED_FLOAT64_ARRAY,\
TYPE_PACKED_INT32_ARRAY,\
TYPE_PACKED_INT64_ARRAY:
var vectors := PackedStringArray()
for vector in value as Array:
vectors.append(str(vector))
if vectors.is_empty():
return GdObjects.type_as_string(type) + "()"
return "%s([%s])" % [GdObjects.type_as_string(type), ", ".join(vectors)]
return "unknown array type %d" % type
func _on_type_Vector(value :Variant, type :int) -> String:
match type:
TYPE_VECTOR2:
if value == Vector2():
return "Vector2()"
return "Vector2%s" % value
TYPE_VECTOR2I:
if value == Vector2i():
return "Vector2i()"
return "Vector2i%s" % value
TYPE_VECTOR3:
if value == Vector3():
return "Vector3()"
return "Vector3%s" % value
TYPE_VECTOR3I:
if value == Vector3i():
return "Vector3i()"
return "Vector3i%s" % value
TYPE_VECTOR4:
if value == Vector4():
return "Vector4()"
return "Vector4%s" % value
TYPE_VECTOR4I:
if value == Vector4i():
return "Vector4i()"
return "Vector4i%s" % value
return "unknown vector type %d" % type
func _on_type_Transform2D(transform :Transform2D) -> String:
if transform == Transform2D():
return "Transform2D()"
return "Transform2D(Vector2%s, Vector2%s, Vector2%s)" % [transform.x, transform.y, transform.origin]
func _on_type_Transform3D(transform :Transform3D) -> String:
if transform == Transform3D():
return "Transform3D()"
return "Transform3D(Vector3%s, Vector3%s, Vector3%s, Vector3%s)" % [transform.basis.x, transform.basis.y, transform.basis.z, transform.origin]
func _on_type_Projection(projection :Projection) -> String:
return "Projection(Vector4%s, Vector4%s, Vector4%s, Vector4%s)" % [projection.x, projection.y, projection.z, projection.w]
@warning_ignore("unused_parameter")
func _on_type_RID(value :RID) -> String:
return "RID()"
func _on_type_Rect2(rect :Rect2) -> String:
if rect == Rect2():
return "Rect2()"
return "Rect2(Vector2%s, Vector2%s)" % [rect.position, rect.size]
func _on_type_Rect2i(rect :Variant) -> String:
if rect == Rect2i():
return "Rect2i()"
return "Rect2i(Vector2i%s, Vector2i%s)" % [rect.position, rect.size]
func _on_type_Plane(plane :Plane) -> String:
if plane == Plane():
return "Plane()"
return "Plane(%d, %d, %d, %d)" % [plane.x, plane.y, plane.z, plane.d]
func _on_type_Quaternion(quaternion :Quaternion) -> String:
if quaternion == Quaternion():
return "Quaternion()"
return "Quaternion(%d, %d, %d, %d)" % [quaternion.x, quaternion.y, quaternion.z, quaternion.w]
func _on_type_AABB(aabb :AABB) -> String:
if aabb == AABB():
return "AABB()"
return "AABB(Vector3%s, Vector3%s)" % [aabb.position, aabb.size]
func _on_type_Basis(basis :Basis) -> String:
if basis == Basis():
return "Basis()"
return "Basis(Vector3%s, Vector3%s, Vector3%s)" % [basis.x, basis.y, basis.z]
static func decode(value :Variant) -> String:
var type := typeof(value)
if GdArrayTools.is_type_array(type) and value.is_empty():
return "<empty>"
var decoder :Callable = instance("GdUnitDefaultValueDecoders", func(): return GdDefaultValueDecoder.new()).get_decoder(type)
if decoder == null:
push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type)
return "null"
if type == TYPE_OBJECT:
return decoder.call(value, type)
return decoder.call(value)
static func decode_typed(type :int, value :Variant) -> String:
if value == null:
return "null"
var decoder :Callable = instance("GdUnitDefaultValueDecoders", func(): return GdDefaultValueDecoder.new()).get_decoder(type)
if decoder == null:
push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type)
return "null"
if type == TYPE_OBJECT:
return decoder.call(value, type)
return decoder.call(value)

View file

@ -0,0 +1,105 @@
class_name GdFunctionArgument
extends RefCounted
var _cleanup_leading_spaces = RegEx.create_from_string("(?m)^[ \t]+")
var _fix_comma_space := RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""")
var _name: String
var _type: int
var _default_value :Variant
var _parameter_sets :PackedStringArray = []
const UNDEFINED :Variant = "<-NO_ARG->"
const ARG_PARAMETERIZED_TEST := "test_parameters"
func _init(p_name :String, p_type :int = TYPE_MAX, value :Variant = UNDEFINED):
_name = p_name
_type = p_type
if p_name == ARG_PARAMETERIZED_TEST:
_parameter_sets = _parse_parameter_set(value)
_default_value = value
func name() -> String:
return _name
func default() -> Variant:
return GodotVersionFixures.convert(_default_value, _type)
func value_as_string() -> String:
if has_default():
return str(_default_value)
return ""
func type() -> int:
return _type
func has_default() -> bool:
return not is_same(_default_value, UNDEFINED)
func is_parameter_set() -> bool:
return _name == ARG_PARAMETERIZED_TEST
func parameter_sets() -> PackedStringArray:
return _parameter_sets
static func get_parameter_set(parameters :Array) -> GdFunctionArgument:
for current in parameters:
if current != null and current.is_parameter_set():
return current
return null
func _to_string() -> String:
var s = _name
if _type != TYPE_MAX:
s += ":" + GdObjects.type_as_string(_type)
if _default_value != UNDEFINED:
s += "=" + str(_default_value)
return s
func _parse_parameter_set(input :String) -> PackedStringArray:
if not input.contains("["):
return []
input = _cleanup_leading_spaces.sub(input, "", true)
input = input.trim_prefix("[").trim_suffix("]").replace("\n", "").trim_prefix(" ")
var single_quote := false
var double_quote := false
var array_end := 0
var current_index = 0
var output :PackedStringArray = []
var start_index := 0
var buf = input.to_ascii_buffer()
for c in buf:
current_index += 1
match c:
# ignore spaces between array elements
32: if array_end == 0:
start_index += 1
continue
# step over array element seperator ','
44: if array_end == 0:
start_index += 1
continue
39: single_quote = !single_quote
34: if not single_quote: double_quote = !double_quote
91: if not double_quote and not single_quote: array_end +=1 # counts array open
93: if not double_quote and not single_quote: array_end -=1 # counts array closed
# if array closed than collect the element
if array_end == 0 and current_index > start_index:
var parameters := input.substr(start_index, current_index-start_index)
parameters = _fix_comma_space.sub(parameters, ", ", true)
output.append(parameters)
start_index = current_index
return output

View file

@ -0,0 +1,250 @@
class_name GdFunctionDescriptor
extends RefCounted
var _is_virtual :bool
var _is_static :bool
var _is_engine :bool
var _is_coroutine :bool
var _name :String
var _line_number :int
var _return_type :int
var _return_class :String
var _args : Array[GdFunctionArgument]
var _varargs :Array[GdFunctionArgument]
func _init(p_name :String,
p_line_number :int,
p_is_virtual :bool,
p_is_static :bool,
p_is_engine :bool,
p_return_type :int,
p_return_class :String,
p_args : Array[GdFunctionArgument],
p_varargs :Array[GdFunctionArgument] = []):
_name = p_name
_line_number = p_line_number
_return_type = p_return_type
_return_class = p_return_class
_is_virtual = p_is_virtual
_is_static = p_is_static
_is_engine = p_is_engine
_is_coroutine = false
_args = p_args
_varargs = p_varargs
func name() -> String:
return _name
func line_number() -> int:
return _line_number
func is_virtual() -> bool:
return _is_virtual
func is_static() -> bool:
return _is_static
func is_engine() -> bool:
return _is_engine
func is_vararg() -> bool:
return not _varargs.is_empty()
func is_coroutine() -> bool:
return _is_coroutine
func is_parameterized() -> bool:
for current in _args:
var arg :GdFunctionArgument = current
if arg.name() == GdFunctionArgument.ARG_PARAMETERIZED_TEST:
return true
return false
func is_private() -> bool:
return name().begins_with("_") and not is_virtual()
func return_type() -> Variant:
return _return_type
func return_type_as_string() -> String:
if (return_type() == TYPE_OBJECT or return_type() == GdObjects.TYPE_ENUM) and not _return_class.is_empty():
return _return_class
return GdObjects.type_as_string(return_type())
func args() -> Array[GdFunctionArgument]:
return _args
func varargs() -> Array[GdFunctionArgument]:
return _varargs
func typeless() -> String:
var func_signature := ""
if _return_type == TYPE_NIL:
func_signature = "func %s(%s) -> void:" % [name(), typeless_args()]
elif _return_type == GdObjects.TYPE_VARIANT:
func_signature = "func %s(%s) -> Variant:" % [name(), typeless_args()]
else:
func_signature = "func %s(%s) -> %s:" % [name(), typeless_args(), return_type_as_string()]
return "static " + func_signature if is_static() else func_signature
func typeless_args() -> String:
var collect := PackedStringArray()
for arg in args():
if arg.has_default():
collect.push_back( arg.name() + "=" + arg.value_as_string())
else:
collect.push_back(arg.name())
for arg in varargs():
collect.push_back(arg.name() + "=" + arg.value_as_string())
return ", ".join(collect)
func typed_args() -> String:
var collect := PackedStringArray()
for arg in args():
collect.push_back(arg._to_string())
for arg in varargs():
collect.push_back(arg._to_string())
return ", ".join(collect)
func _to_string() -> String:
var fsignature := "virtual " if is_virtual() else ""
if _return_type == TYPE_NIL:
return fsignature + "[Line:%s] func %s(%s):" % [line_number(), name(), typed_args()]
var func_template := fsignature + "[Line:%s] func %s(%s) -> %s:"
if is_static():
func_template= "[Line:%s] static func %s(%s) -> %s:"
return func_template % [line_number(), name(), typed_args(), return_type_as_string()]
# extract function description given by Object.get_method_list()
static func extract_from(descriptor :Dictionary) -> GdFunctionDescriptor:
var function_flags :int = descriptor["flags"]
var is_virtual_ :bool = function_flags & METHOD_FLAG_VIRTUAL
var is_static_ :bool = function_flags & METHOD_FLAG_STATIC
var is_vararg_ :bool = function_flags & METHOD_FLAG_VARARG
#var is_const :bool = function_flags & METHOD_FLAG_CONST
#var is_core :bool = function_flags & METHOD_FLAG_OBJECT_CORE
#var is_default :bool = function_flags & METHOD_FLAGS_DEFAULT
#prints("is_virtual: ", is_virtual)
#prints("is_static: ", is_static)
#prints("is_const: ", is_const)
#prints("is_core: ", is_core)
#prints("is_default: ", is_default)
#prints("is_vararg: ", is_vararg)
return GdFunctionDescriptor.new(
descriptor["name"],
-1,
is_virtual_,
is_static_,
true,
_extract_return_type(descriptor["return"]),
descriptor["return"]["class_name"],
_extract_args(descriptor),
_build_varargs(is_vararg_)
)
# temporary exclude GlobalScope enums
const enum_fix := [
"Side",
"Corner",
"Orientation",
"ClockDirection",
"HorizontalAlignment",
"VerticalAlignment",
"InlineAlignment",
"EulerOrder",
"Error",
"Key",
"MIDIMessage",
"MouseButton",
"MouseButtonMask",
"JoyButton",
"JoyAxis",
"PropertyHint",
"PropertyUsageFlags",
"MethodFlags",
"Variant.Type",
"Control.LayoutMode"]
static func _extract_return_type(return_info :Dictionary) -> Variant:
var type :int = return_info["type"]
var usage :int = return_info["usage"]
if type == TYPE_INT and usage & PROPERTY_USAGE_CLASS_IS_ENUM:
return GdObjects.TYPE_ENUM
if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT:
return GdObjects.TYPE_VARIANT
return type
static func _extract_args(descriptor :Dictionary) -> Array[GdFunctionArgument]:
var args_ :Array[GdFunctionArgument] = []
var arguments :Array = descriptor["args"]
var defaults :Array = descriptor["default_args"]
# iterate backwards because the default values are stored from right to left
while not arguments.is_empty():
var arg :Dictionary = arguments.pop_back()
var arg_name := _argument_name(arg)
var arg_type := _argument_type(arg)
var arg_default :Variant = GdFunctionArgument.UNDEFINED
if not defaults.is_empty():
var default_value = defaults.pop_back()
arg_default = GdDefaultValueDecoder.decode_typed(arg_type, default_value)
args_.push_front(GdFunctionArgument.new(arg_name, arg_type, arg_default))
return args_
static func _build_varargs(p_is_vararg :bool) -> Array[GdFunctionArgument]:
var varargs_ :Array[GdFunctionArgument] = []
if not p_is_vararg:
return varargs_
# if function has vararg we need to handle this manually by adding 10 default arguments
var type := GdObjects.TYPE_VARARG
for index in 10:
varargs_.push_back(GdFunctionArgument.new("vararg%d_" % index, type, "\"%s\"" % GdObjects.TYPE_VARARG_PLACEHOLDER_VALUE))
return varargs_
static func _argument_name(arg :Dictionary) -> String:
# add suffix to the name to prevent clash with reserved names
return (arg["name"] + "_") as String
static func _argument_type(arg :Dictionary) -> int:
var type :int = arg["type"]
if type == TYPE_OBJECT:
if arg["class_name"] == "Node":
return GdObjects.TYPE_NODE
return type
static func _argument_type_as_string(arg :Dictionary) -> String:
var type := _argument_type(arg)
match type:
TYPE_NIL:
return ""
TYPE_OBJECT:
var clazz_name :String = arg["class_name"]
if not clazz_name.is_empty():
return clazz_name
return ""
_:
return GdObjects.type_as_string(type)

View file

@ -0,0 +1,824 @@
class_name GdScriptParser
extends RefCounted
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const ALLOWED_CHARACTERS := "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\""
var TOKEN_NOT_MATCH := Token.new("")
var TOKEN_SPACE := SkippableToken.new(" ")
var TOKEN_TABULATOR := SkippableToken.new("\t")
var TOKEN_NEW_LINE := SkippableToken.new("\n")
var TOKEN_COMMENT := SkippableToken.new("#")
var TOKEN_CLASS_NAME := Token.new("class_name")
var TOKEN_INNER_CLASS := Token.new("class")
var TOKEN_EXTENDS := Token.new("extends")
var TOKEN_ENUM := Token.new("enum")
var TOKEN_FUNCTION_STATIC_DECLARATION := Token.new("staticfunc")
var TOKEN_FUNCTION_DECLARATION := Token.new("func")
var TOKEN_FUNCTION := Token.new(".")
var TOKEN_FUNCTION_RETURN_TYPE := Token.new("->")
var TOKEN_FUNCTION_END := Token.new("):")
var TOKEN_ARGUMENT_ASIGNMENT := Token.new("=")
var TOKEN_ARGUMENT_TYPE_ASIGNMENT := Token.new(":=")
var TOKEN_ARGUMENT_FUZZER := FuzzerToken.new(GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)"))
var TOKEN_ARGUMENT_TYPE := Token.new(":")
var TOKEN_ARGUMENT_SEPARATOR := Token.new(",")
var TOKEN_BRACKET_OPEN := Token.new("(")
var TOKEN_BRACKET_CLOSE := Token.new(")")
var TOKEN_ARRAY_OPEN := Token.new("[")
var TOKEN_ARRAY_CLOSE := Token.new("]")
var OPERATOR_ADD := Operator.new("+")
var OPERATOR_SUB := Operator.new("-")
var OPERATOR_MUL := Operator.new("*")
var OPERATOR_DIV := Operator.new("/")
var OPERATOR_REMAINDER := Operator.new("%")
var TOKENS := [
TOKEN_SPACE,
TOKEN_TABULATOR,
TOKEN_NEW_LINE,
TOKEN_COMMENT,
TOKEN_BRACKET_OPEN,
TOKEN_BRACKET_CLOSE,
TOKEN_ARRAY_OPEN,
TOKEN_ARRAY_CLOSE,
TOKEN_CLASS_NAME,
TOKEN_INNER_CLASS,
TOKEN_EXTENDS,
TOKEN_ENUM,
TOKEN_FUNCTION_STATIC_DECLARATION,
TOKEN_FUNCTION_DECLARATION,
TOKEN_ARGUMENT_FUZZER,
TOKEN_ARGUMENT_TYPE_ASIGNMENT,
TOKEN_ARGUMENT_ASIGNMENT,
TOKEN_ARGUMENT_TYPE,
TOKEN_FUNCTION,
TOKEN_ARGUMENT_SEPARATOR,
TOKEN_FUNCTION_RETURN_TYPE,
OPERATOR_ADD,
OPERATOR_SUB,
OPERATOR_MUL,
OPERATOR_DIV,
OPERATOR_REMAINDER,
]
var _regex_clazz_name :RegEx
var _regex_strip_comments := GdUnitTools.to_regex("^([^#\"']|'[^']*'|\"[^\"]*\")*\\K#.*")
var _base_clazz :String
var _scanned_inner_classes := PackedStringArray()
var _script_constants := {}
static func clean_up_row(row :String) -> String:
return to_unix_format(row.replace(" ", "").replace("\t", ""))
static func to_unix_format(input :String) -> String:
return input.replace("\r\n", "\n")
class Token extends RefCounted:
var _token: String
var _consumed: int
var _is_operator: bool
var _regex :RegEx
func _init(p_token: String, p_is_operator := false, p_regex :RegEx = null) -> void:
_token = p_token
_is_operator = p_is_operator
_consumed = p_token.length()
_regex = p_regex
func match(input: String, pos: int) -> bool:
if _regex:
var result := _regex.search(input, pos)
if result == null:
return false
_consumed = result.get_end() - result.get_start()
return pos == result.get_start()
return input.findn(_token, pos) == pos
func is_operator() -> bool:
return _is_operator
func is_inner_class() -> bool:
return _token == "class"
func is_variable() -> bool:
return false
func is_token(token_name :String) -> bool:
return _token == token_name
func is_skippable() -> bool:
return false
func _to_string():
return "Token{" + _token + "}"
class Operator extends Token:
func _init(value: String):
super(value, true)
func _to_string():
return "OperatorToken{%s}" % [_token]
# A skippable token, is just a placeholder like space or tabs
class SkippableToken extends Token:
func _init(p_token: String):
super(p_token)
func is_skippable() -> bool:
return true
# Token to parse Fuzzers
class FuzzerToken extends Token:
var _name: String
func _init(regex: RegEx):
super("", false, regex)
func match(input: String, pos: int) -> bool:
if _regex:
var result := _regex.search(input, pos)
if result == null:
return false
_name = result.strings[1]
_consumed = result.get_end() - result.get_start()
return pos == result.get_start()
return input.findn(_token, pos) == pos
func name() -> String:
return _name
func type() -> int:
return GdObjects.TYPE_FUZZER
func _to_string():
return "FuzzerToken{%s: '%s'}" % [_name, _token]
# Token to parse function arguments
class Variable extends Token:
var _plain_value
var _typed_value
var _type :int = TYPE_NIL
func _init(p_value: String):
super(p_value)
_type = _scan_type(p_value)
_plain_value = p_value
_typed_value = _cast_to_type(p_value, _type)
func _scan_type(p_value: String) -> int:
if p_value.begins_with("\"") and p_value.ends_with("\""):
return TYPE_STRING
var type_ := GdObjects.string_to_type(p_value)
if type_ != TYPE_NIL:
return type_
if p_value.is_valid_int():
return TYPE_INT
if p_value.is_valid_float():
return TYPE_FLOAT
if p_value.is_valid_hex_number():
return TYPE_INT
return TYPE_OBJECT
func _cast_to_type(p_value :String, p_type: int) -> Variant:
match p_type:
TYPE_STRING:
return p_value#.substr(1, p_value.length() - 2)
TYPE_INT:
return p_value.to_int()
TYPE_FLOAT:
return p_value.to_float()
return p_value
func is_variable() -> bool:
return true
func type() -> int:
return _type
func value():
return _typed_value
func plain_value():
return _plain_value
func _to_string():
return "Variable{%s: %s : '%s'}" % [_plain_value, GdObjects.type_as_string(_type), _token]
class TokenInnerClass extends Token:
var _clazz_name
var _content := PackedStringArray()
static func _strip_leading_spaces(input :String) -> String:
var characters := input.to_ascii_buffer()
while not characters.is_empty():
if characters[0] != 0x20:
break
characters.remove_at(0)
return characters.get_string_from_ascii()
static func _consumed_bytes(row :String) -> int:
return row.replace(" ", "").replace(" ", "").length()
func _init(clazz_name :String):
super("class")
_clazz_name = clazz_name
func is_class_name(clazz_name :String) -> bool:
return _clazz_name == clazz_name
func content() -> PackedStringArray:
return _content
func parse(source_rows :PackedStringArray, offset :int) -> void:
# add class signature
_content.append(source_rows[offset])
# parse class content
for row_index in range(offset+1, source_rows.size()):
# scan until next non tab
var source_row := source_rows[row_index]
var row = TokenInnerClass._strip_leading_spaces(source_row)
if row.is_empty() or row.begins_with("\t") or row.begins_with("#"):
# fold all line to left by removing leading tabs and spaces
if source_row.begins_with("\t"):
source_row = source_row.trim_prefix("\t")
# refomat invalid empty lines
if source_row.dedent().is_empty():
_content.append("")
else:
_content.append(source_row)
continue
break
_consumed += TokenInnerClass._consumed_bytes("".join(_content))
func _to_string():
return "TokenInnerClass{%s}" % [_clazz_name]
func _init():
_regex_clazz_name = GdUnitTools.to_regex("(class)([a-zA-Z0-9]+)(extends[a-zA-Z]+:)|(class)([a-zA-Z0-9]+)(:)")
func get_token(input :String, current_index) -> Token:
for t in TOKENS:
if t.match(input, current_index):
return t
return TOKEN_NOT_MATCH
func next_token(input: String, current_index: int, ignore_tokens :Array[Token] = []) -> Token:
var token := TOKEN_NOT_MATCH
for t in TOKENS.filter(func(token): return not ignore_tokens.has(token)):
if t.match(input, current_index):
token = t
break
if token == OPERATOR_SUB:
token = tokenize_value(input, current_index, token)
if token == TOKEN_INNER_CLASS:
token = tokenize_inner_class(input, current_index, token)
if token == TOKEN_NOT_MATCH:
return tokenize_value(input, current_index, token, ignore_tokens.has(TOKEN_FUNCTION))
return token
func tokenize_value(input: String, current: int, token: Token, ignore_dots := false) -> Token:
var next := 0
var current_token := ""
# test for '--', '+-', '*-', '/-', '%-', or at least '-x'
var test_for_sign := (token == null or token.is_operator()) and input[current] == "-"
while current + next < len(input):
var character := input[current + next] as String
# if first charater a sign
# or allowend charset
# or is a float value
if (test_for_sign and next==0) \
or character in ALLOWED_CHARACTERS \
or (character == "." and (ignore_dots or current_token.is_valid_int())):
current_token += character
next += 1
continue
break
if current_token != "":
return Variable.new(current_token)
return TOKEN_NOT_MATCH
func extract_clazz_name(value :String) -> String:
var result := _regex_clazz_name.search(value)
if result == null:
push_error("Can't extract class name from '%s'" % value)
return ""
if result.get_string(2).is_empty():
return result.get_string(5)
else:
return result.get_string(2)
@warning_ignore("unused_parameter")
func tokenize_inner_class(source_code: String, current: int, token: Token) -> Token:
var clazz_name := extract_clazz_name(source_code.substr(current, 64))
return TokenInnerClass.new(clazz_name)
@warning_ignore("assert_always_false")
func _process_values(left: Token, token_stack: Array, operator: Token) -> Token:
# precheck
if left.is_variable() and operator.is_operator():
var lvalue = left.value()
var value = null
var next_token_ = token_stack.pop_front() as Token
match operator:
OPERATOR_ADD:
value = lvalue + next_token_.value()
OPERATOR_SUB:
value = lvalue - next_token_.value()
OPERATOR_MUL:
value = lvalue * next_token_.value()
OPERATOR_DIV:
value = lvalue / next_token_.value()
OPERATOR_REMAINDER:
value = lvalue & next_token_.value()
_:
assert(false, "Unsupported operator %s" % operator)
return Variable.new( str(value))
return operator
func parse_func_return_type(row: String) -> int:
var token := parse_return_token(row)
if token == TOKEN_NOT_MATCH:
return TYPE_NIL
return token.type()
func parse_return_token(input: String) -> Token:
var index := input.rfind(TOKEN_FUNCTION_RETURN_TYPE._token)
if index == -1:
return TOKEN_NOT_MATCH
index += TOKEN_FUNCTION_RETURN_TYPE._consumed
# We scan for the return value exclusive '.' token because it could be referenced to a
# external or internal class e.g. 'func foo() -> InnerClass.Bar:'
var token := next_token(input, index, [TOKEN_FUNCTION])
while !token.is_variable() and token != TOKEN_NOT_MATCH:
index += token._consumed
token = next_token(input, index, [TOKEN_FUNCTION])
return token
# Parses the argument into a argument signature
# e.g. func foo(arg1 :int, arg2 = 20) -> [arg1, arg2]
func parse_arguments(input: String) -> Array[GdFunctionArgument]:
var args :Array[GdFunctionArgument] = []
var current_index := 0
var token :Token = null
var bracket := 0
var in_function := false
while current_index < len(input):
token = next_token(input, current_index)
# fallback to not end in a endless loop
if token == TOKEN_NOT_MATCH:
var error : = """
Parsing Error: Invalid token at pos %d found.
Please report this error!
source_code:
--------------------------------------------------------------
%s
--------------------------------------------------------------
""".dedent() % [current_index, input]
push_error(error)
current_index += 1
continue
current_index += token._consumed
if token.is_skippable():
continue
if token == TOKEN_BRACKET_OPEN:
in_function = true
bracket += 1
continue
if token == TOKEN_BRACKET_CLOSE:
bracket -= 1
# if function end?
if in_function and bracket == 0:
return args
# is function
if token == TOKEN_FUNCTION_DECLARATION:
token = next_token(input, current_index)
current_index += token._consumed
continue
# is fuzzer argument
if token is FuzzerToken:
var arg_value := _parse_end_function(input.substr(current_index), true)
current_index += arg_value.length()
args.append(GdFunctionArgument.new(token.name(), token.type(), arg_value))
continue
# is value argument
if in_function and token.is_variable():
var arg_name :String = token.plain_value()
var arg_type :int = TYPE_NIL
var arg_value = GdFunctionArgument.UNDEFINED
# parse type and default value
while current_index < len(input):
token = next_token(input, current_index)
current_index += token._consumed
if token.is_skippable():
continue
match token:
TOKEN_ARGUMENT_TYPE:
token = next_token(input, current_index)
if token == TOKEN_SPACE:
current_index += token._consumed
token = next_token(input, current_index)
arg_type = GdObjects.string_as_typeof(token._token)
TOKEN_ARGUMENT_TYPE_ASIGNMENT:
arg_value = _parse_end_function(input.substr(current_index), true)
current_index += arg_value.length()
TOKEN_ARGUMENT_ASIGNMENT:
token = next_token(input, current_index)
arg_value = _parse_end_function(input.substr(current_index), true)
current_index += arg_value.length()
TOKEN_BRACKET_OPEN:
bracket += 1
# if value a function?
if bracket > 1:
# complete the argument value
var func_begin = input.substr(current_index-TOKEN_BRACKET_OPEN._consumed)
var func_body = _parse_end_function(func_begin)
arg_value += func_body
# fix parse index to end of value
current_index += func_body.length() - TOKEN_BRACKET_OPEN._consumed - TOKEN_BRACKET_CLOSE._consumed
TOKEN_BRACKET_CLOSE:
bracket -= 1
# end of function
if bracket == 0:
break
TOKEN_ARGUMENT_SEPARATOR:
if bracket <= 1:
break
arg_value = arg_value.lstrip(" ")
if arg_type == TYPE_NIL and arg_value != GdFunctionArgument.UNDEFINED:
if arg_value.begins_with("Color."):
arg_type = TYPE_COLOR
elif arg_value.begins_with("Vector2."):
arg_type = TYPE_VECTOR2
elif arg_value.begins_with("Vector3."):
arg_type = TYPE_VECTOR3
elif arg_value.begins_with("AABB("):
arg_type = TYPE_AABB
elif arg_value.begins_with("["):
arg_type = TYPE_ARRAY
elif arg_value.begins_with("{"):
arg_type = TYPE_DICTIONARY
else:
arg_type = typeof(str_to_var(arg_value))
if arg_value.rfind(")") == arg_value.length()-1:
arg_type = GdObjects.TYPE_FUNC
elif arg_type == TYPE_NIL:
arg_type = TYPE_STRING
args.append(GdFunctionArgument.new(arg_name, arg_type, arg_value))
return args
# Parse an string for an argument with given name <argument_name> and returns the value
# if the argument not found the <default_value> is returned
func parse_argument(row: String, argument_name: String, default_value):
var input := GdScriptParser.clean_up_row(row)
var argument_found := false
var current_index := 0
var token :Token = null
while current_index < len(input):
token = next_token(input, current_index) as Token
current_index += token._consumed
if token == TOKEN_NOT_MATCH:
return default_value
if not argument_found and not token.is_token(argument_name):
continue
argument_found = true
# extract value
if token == TOKEN_ARGUMENT_TYPE_ASIGNMENT:
token = next_token(input, current_index) as Token
return token.value()
elif token == TOKEN_ARGUMENT_ASIGNMENT:
token = next_token(input, current_index) as Token
return token.value()
return default_value
func _parse_end_function(input: String, remove_trailing_char := false) -> String:
# find end of function
var current_index := 0
var bracket_count := 0
var in_array := 0
var end_of_func = false
while current_index < len(input) and not end_of_func:
var character = input[current_index]
# step over strings
if character == "'" :
current_index = input.find("'", current_index+1) + 1
if current_index == 0:
push_error("Parsing error on '%s', can't evaluate end of string." % input)
return ""
continue
if character == '"' :
# test for string blocks
if input.find('"""', current_index) == current_index:
current_index = input.find('"""', current_index+3) + 3
else:
current_index = input.find('"', current_index+1) + 1
if current_index == 0:
push_error("Parsing error on '%s', can't evaluate end of string." % input)
return ""
continue
match character:
# count if inside an array
"[": in_array += 1
"]": in_array -= 1
# count if inside a function
"(": bracket_count += 1
")":
bracket_count -= 1
if bracket_count < 0 and in_array <= 0:
end_of_func = true
",":
if bracket_count == 0 and in_array == 0:
end_of_func = true
current_index += 1
if remove_trailing_char:
# check if the parsed value ends with comma or end of doubled breaked
# `<value>,` or `<function>())`
var trailing_char := input[current_index-1]
if trailing_char == ',' or (bracket_count < 0 and trailing_char == ')'):
return input.substr(0, current_index-1)
return input.substr(0, current_index)
func extract_inner_class(source_rows: PackedStringArray, clazz_name :String) -> PackedStringArray:
for row_index in source_rows.size():
var input := GdScriptParser.clean_up_row(source_rows[row_index])
var token := next_token(input, 0)
if token.is_inner_class():
if token.is_class_name(clazz_name):
token.parse(source_rows, row_index)
return token.content()
return PackedStringArray()
func extract_source_code(script_path :PackedStringArray) -> PackedStringArray:
if script_path.is_empty():
push_error("Invalid script path '%s'" % script_path)
return PackedStringArray()
#load the source code
var resource_path := script_path[0]
var script :GDScript = load(resource_path)
var source_code := load_source_code(script, script_path)
var base_script := script.get_base_script()
if base_script:
_base_clazz = GdObjects.extract_class_name_from_class_path([base_script.resource_path])
source_code += load_source_code(base_script, script_path)
return source_code
func extract_func_signature(rows :PackedStringArray, index :int) -> String:
var signature := ""
for rowIndex in range(index, rows.size()):
var row := rows[rowIndex]
row = _regex_strip_comments.sub(row, "").strip_edges(false)
if row.is_empty():
continue
signature += row + "\n"
if is_func_end(row):
return signature.strip_edges()
push_error("Can't fully extract function signature of '%s'" % rows[index])
return ""
func load_source_code(script :GDScript, script_path :PackedStringArray) -> PackedStringArray:
var map := script.get_script_constant_map()
for key in map.keys():
var value = map.get(key)
if value is GDScript:
var class_path := GdObjects.extract_class_path(value)
if class_path.size() > 1:
_scanned_inner_classes.append(class_path[1])
var source_code := GdScriptParser.to_unix_format(script.source_code)
var source_rows := source_code.split("\n")
# extract all inner class names
# want to extract an inner class?
if script_path.size() > 1:
var inner_clazz = script_path[1]
source_rows = extract_inner_class(source_rows, inner_clazz)
return PackedStringArray(source_rows)
func get_class_name(script :GDScript) -> String:
var source_code := GdScriptParser.to_unix_format(script.source_code)
var source_rows := source_code.split("\n")
for index in min(10, source_rows.size()):
var input = GdScriptParser.clean_up_row(source_rows[index])
var token := next_token(input, 0)
if token == TOKEN_CLASS_NAME:
token = tokenize_value(input, token._consumed, token)
return token.value()
# if no class_name found extract from file name
return GdObjects.to_pascal_case(script.resource_path.get_basename().get_file())
func parse_func_name(row :String) -> String:
var input = GdScriptParser.clean_up_row(row)
var current_index = 0
var token := next_token(input, current_index)
current_index += token._consumed
if token != TOKEN_FUNCTION_STATIC_DECLARATION and token != TOKEN_FUNCTION_DECLARATION:
return ""
while not token is Variable:
token = next_token(input, current_index)
current_index += token._consumed
return token._token
func parse_functions(rows :PackedStringArray, clazz_name :String, clazz_path :PackedStringArray, included_functions := PackedStringArray()) -> Array[GdFunctionDescriptor]:
var func_descriptors :Array[GdFunctionDescriptor] = []
for rowIndex in rows.size():
var row = rows[rowIndex]
# step over inner class functions
if row.begins_with("\t"):
continue
var input = GdScriptParser.clean_up_row(row)
# skip comments and empty lines
if input.begins_with("#") or input.length() == 0:
continue
var token := next_token(input, 0)
if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION:
if _is_func_included(input, included_functions):
var func_signature = extract_func_signature(rows, rowIndex)
var fd := parse_func_description(func_signature, clazz_name, clazz_path, rowIndex+1)
fd._is_coroutine = is_func_coroutine(rows, rowIndex)
func_descriptors.append(fd)
return func_descriptors
func is_func_coroutine(rows :PackedStringArray, index :int) -> bool:
var is_coroutine := false
for rowIndex in range( index+1, rows.size()):
var row = rows[rowIndex]
is_coroutine = row.contains("await")
if is_coroutine:
return true
var input = GdScriptParser.clean_up_row(row)
var token := next_token(input, 0)
if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION:
break
return is_coroutine
func _is_func_included(row :String, included_functions :PackedStringArray) -> bool:
if included_functions.is_empty():
return true
for name in included_functions:
if row.find(name) != -1:
return true
return false
func parse_func_description(func_signature :String, clazz_name :String, clazz_path :PackedStringArray, line_number :int) -> GdFunctionDescriptor:
var name = parse_func_name(func_signature)
var return_type :int
var return_clazz := ""
var token := parse_return_token(func_signature)
if token == TOKEN_NOT_MATCH:
return_type = GdObjects.TYPE_VARIANT
else:
return_type = token.type()
if token.type() == TYPE_OBJECT:
return_clazz = _patch_inner_class_names(token.value(), clazz_name)
# is return type an enum?
if is_class_enum_type(return_clazz):
return_type = GdObjects.TYPE_ENUM
return GdFunctionDescriptor.new(
name,
line_number,
is_virtual_func(clazz_name, clazz_path, name),
is_static_func(func_signature),
false,
return_type,
return_clazz,
parse_arguments(func_signature)
)
# caches already parsed classes for virtual functions
# key: <clazz_name> value: a Array of virtual function names
var _virtual_func_cache := Dictionary()
func is_virtual_func(clazz_name :String, clazz_path :PackedStringArray, func_name :String) -> bool:
if _virtual_func_cache.has(clazz_name):
return _virtual_func_cache[clazz_name].has(func_name)
var virtual_functions := Array()
var method_list := GdObjects.extract_class_functions(clazz_name, clazz_path)
for method_descriptor in method_list:
var is_virtual_function :bool = method_descriptor["flags"] & METHOD_FLAG_VIRTUAL
if is_virtual_function:
virtual_functions.append(method_descriptor["name"])
_virtual_func_cache[clazz_name] = virtual_functions
return _virtual_func_cache[clazz_name].has(func_name)
func is_static_func(func_signature :String) -> bool:
var input := GdScriptParser.clean_up_row(func_signature)
var token := next_token(input, 0)
return token == TOKEN_FUNCTION_STATIC_DECLARATION
func is_inner_class(clazz_path :PackedStringArray) -> bool:
return clazz_path.size() > 1
func is_func_end(row :String) -> bool:
return row.strip_edges(false, true).ends_with(":")
func is_class_enum_type(value :String) -> bool:
# first check is given value a enum from the current class
if _script_constants.has(value):
return true
# otherwise we need to determie it by reflection
var script := GDScript.new()
script.source_code = """
extends Resource
static func is_class_enum_type() -> bool:
return typeof(%s) == TYPE_DICTIONARY
""".dedent() % value
script.reload()
return script.call("is_class_enum_type")
func _patch_inner_class_names(clazz :String, clazz_name :String) -> String:
var base_clazz := clazz_name.split(".")[0]
var inner_clazz_name := clazz.split(".")[0]
if _scanned_inner_classes.has(inner_clazz_name):
return base_clazz + "." + clazz
if _script_constants.has(clazz):
return clazz_name + "." + clazz
return clazz
func extract_functions(script :GDScript, clazz_name :String, clazz_path :PackedStringArray) -> Array[GdFunctionDescriptor]:
var source_code := load_source_code(script, clazz_path)
_script_constants = script.get_script_constant_map()
return parse_functions(source_code, clazz_name, clazz_path)
func parse(clazz_name :String, clazz_path :PackedStringArray) -> GdUnitResult:
if clazz_path.is_empty():
return GdUnitResult.error("Invalid script path '%s'" % clazz_path)
var is_inner_class_ := is_inner_class(clazz_path)
var script :GDScript = load(clazz_path[0])
var function_descriptors := extract_functions(script, clazz_name, clazz_path)
var gd_class := GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors)
# iterate over class dependencies
script = script.get_base_script()
while script != null:
clazz_name = GdObjects.extract_class_name_from_class_path([script.resource_path])
function_descriptors = extract_functions(script, clazz_name, clazz_path)
gd_class.set_parent_clazz(GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors))
script = script.get_base_script()
return GdUnitResult.success(gd_class)

View file

@ -0,0 +1,26 @@
class_name GdUnitExpressionRunner
extends RefCounted
const CLASS_TEMPLATE = """
class_name _ExpressionRunner extends '${clazz_path}'
func __run_expression() -> Variant:
return $expression
"""
func execute(src_script :GDScript, expression :String) -> Variant:
var script := GDScript.new()
var resource_path := "res://addons/gdUnit4/src/Fuzzers.gd" if src_script.resource_path.is_empty() else src_script.resource_path
script.source_code = CLASS_TEMPLATE.dedent()\
.replace("${clazz_path}", resource_path)\
.replace("$expression", expression)
script.reload(false)
var runner :Variant = script.new()
if runner.has_method("queue_free"):
runner.queue_free()
return runner.__run_expression()
func to_fuzzer(src_script :GDScript, expression :String) -> Fuzzer:
return execute(src_script, expression) as Fuzzer

View file

@ -0,0 +1,194 @@
class_name GdUnitTestParameterSetResolver
extends RefCounted
const CLASS_TEMPLATE = """
class_name _ParameterExtractor extends '${clazz_path}'
func __extract_test_parameters() -> Array:
return ${test_params}
"""
const EXCLUDE_PROPERTIES_TO_COPY = [
"script",
"type",
"Node",
"_import_path"]
var _fd: GdFunctionDescriptor
var _test_case_names_cache := PackedStringArray()
var _static_sets_by_index := {}
var _is_static := true
func _init(fd: GdFunctionDescriptor) -> void:
_fd = fd
func is_parameterized() -> bool:
return _fd.is_parameterized()
func is_parameter_sets_static() -> bool:
return _is_static
func is_parameter_set_static(index: int) -> bool:
return _is_static and _static_sets_by_index.get(index, false)
# validates the given arguments are complete and matches to required input fields of the test function
func validate(input_value_set: Array) -> String:
var input_arguments := _fd.args()
# check given parameter set with test case arguments
var expected_arg_count = input_arguments.size() - 1
for input_values in input_value_set:
var parameter_set_index := input_value_set.find(input_values)
if input_values is Array:
var current_arg_count = input_values.size()
if current_arg_count != expected_arg_count:
return "\n The parameter set at index [%d] does not match the expected input parameters!\n The test case requires [%d] input parameters, but the set contains [%d]" % [parameter_set_index, expected_arg_count, current_arg_count]
var error := validate_parameter_types(input_arguments, input_values, parameter_set_index)
if not error.is_empty():
return error
else:
return "\n The parameter set at index [%d] does not match the expected input parameters!\n Expecting an array of input values." % parameter_set_index
return ""
static func validate_parameter_types(input_arguments: Array, input_values: Array, parameter_set_index: int) -> String:
for i in input_arguments.size():
var input_param: GdFunctionArgument = input_arguments[i]
# only check the test input arguments
if input_param.is_parameter_set():
continue
var input_param_type := input_param.type()
var input_value = input_values[i]
var input_value_type := typeof(input_value)
# input parameter is not typed we skip the type test
if input_param_type == TYPE_NIL:
continue
# is input type enum allow int values
if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT:
continue
# allow only equal types and object == null
if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL:
continue
if input_param_type != input_value_type:
return "\n The parameter set at index [%d] does not match the expected input parameters!\n The value '%s' does not match the required input parameter <%s>." % [parameter_set_index, input_value, input_param]
return ""
func build_test_case_names(test_case: _TestCase) -> PackedStringArray:
if not is_parameterized():
return []
# if test names already resolved?
if not _test_case_names_cache.is_empty():
return _test_case_names_cache
var fa := GdFunctionArgument.get_parameter_set(_fd.args())
var parameter_sets := fa.parameter_sets()
# if no parameter set detected we need to resolve it by using reflection
if parameter_sets.size() == 0:
_test_case_names_cache = _extract_test_names_by_reflection(test_case)
_is_static = false
else:
var property_names := _extract_property_names(test_case.get_parent())
for parameter_set_index in parameter_sets.size():
var parameter_set := parameter_sets[parameter_set_index]
_static_sets_by_index[parameter_set_index] = _is_static_parameter_set(test_case, parameter_set, property_names)
_test_case_names_cache.append(_build_test_case_name(test_case, parameter_set, parameter_set_index))
parameter_set_index += 1
return _test_case_names_cache
func _extract_property_names(node :Node) -> PackedStringArray:
return node.get_property_list()\
.map(func(property): return property["name"])\
.filter(func(property): return !EXCLUDE_PROPERTIES_TO_COPY.has(property))
# tests if the test property set contains an property reference by name, if not the parameter set holds only static values
func _is_static_parameter_set(test_case: _TestCase, parameters :String, property_names :PackedStringArray) -> bool:
for property_name in property_names:
if parameters.contains(property_name):
_is_static = false
return false
return true
func _extract_test_names_by_reflection(test_case: _TestCase) -> PackedStringArray:
var parameter_sets := load_parameter_sets(test_case)
var test_case_names: PackedStringArray = []
for index in parameter_sets.size():
test_case_names.append(_build_test_case_name(test_case, str(parameter_sets[index]), index))
return test_case_names
static func _build_test_case_name(test_case: _TestCase, test_parameter: String, parameter_set_index: int) -> String:
if not test_parameter.begins_with("["):
test_parameter = "[" + test_parameter
return "%s:%d %s" % [test_case.get_name(), parameter_set_index, test_parameter.replace("\t", "").replace('"', "'").replace("&'", "'")]
# extracts the arguments from the given test case, using kind of reflection solution
# to restore the parameters from a string representation to real instance type
func load_parameter_sets(test_case: _TestCase, validate := false) -> Array:
var source_script = test_case.get_parent().get_script()
var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args())
var source_code = CLASS_TEMPLATE \
.replace("${clazz_path}", source_script.resource_path) \
.replace("${test_params}", parameter_arg.value_as_string())
var script = GDScript.new()
script.source_code = source_code
# enable this lines only for debuging
#script.resource_path = GdUnitTools.create_temp_dir("parameter_extract") + "/%s__.gd" % fd.name()
#DirAccess.remove_absolute(script.resource_path)
#ResourceSaver.save(script, script.resource_path)
var result = script.reload()
if result != OK:
push_error("Extracting test parameters failed! Script loading error: %s" % result)
return []
var instance = script.new()
copy_properties(test_case.get_parent(), instance)
instance.queue_free()
var parameter_sets = instance.call("__extract_test_parameters")
if not validate:
return parameter_sets
# validate the parameter set
var error := validate(parameter_sets)
if not error.is_empty():
test_case.skip(true, error)
test_case._interupted = true
if parameter_sets.size() != _test_case_names_cache.size():
push_error("Internal Error: The resolved test_case names has invalid size!")
error = """
%s:
The resolved test_case names has invalid size!
%s
""".dedent().trim_prefix("\n") % [
GdAssertMessages._error("Internal Error"),
GdAssertMessages._error("Please report this issue as a bug!")]
test_case.get_parent().__execution_context\
.reports()\
.append(GdUnitReport.new().create(GdUnitReport.INTERUPTED, test_case.line_number(), error))
test_case.skip(true, error)
test_case._interupted = true
return parameter_sets
static func copy_properties(source: Object, dest: Object) -> void:
var context := GdUnitThreadManager.get_current_context().get_execution_context()
for property in source.get_property_list():
var property_name = property["name"]
var property_value = source.get(property_name)
if EXCLUDE_PROPERTIES_TO_COPY.has(property_name):
continue
#if dest.get(property_name) == null:
# prints("|%s|" % property_name, source.get(property_name))
# check for invalid name property
if property_name == "name" and property_value == "":
dest.set(property_name, "<empty>");
continue
dest.set(property_name, property_value)

View file

@ -0,0 +1,74 @@
class_name GdUnitReport
extends Resource
# report type
enum {
SUCCESS,
WARN,
FAILURE,
ORPHAN,
TERMINATED,
INTERUPTED,
ABORT,
SKIPPED,
}
var _type :int
var _line_number :int
var _message :String
func create(p_type :int, p_line_number :int, p_message :String) -> GdUnitReport:
_type = p_type
_line_number = p_line_number
_message = p_message
return self
func type() -> int:
return _type
func line_number() -> int:
return _line_number
func message() -> String:
return _message
func is_skipped() -> bool:
return _type == SKIPPED
func is_warning() -> bool:
return _type == WARN
func is_failure() -> bool:
return _type == FAILURE
func is_error() -> bool:
return _type == TERMINATED or _type == INTERUPTED or _type == ABORT
func _to_string() -> String:
if _line_number == -1:
return "[color=green]line [/color][color=aqua]<n/a>:[/color] %s" % [_message]
return "[color=green]line [/color][color=aqua]%d:[/color] %s" % [_line_number, _message]
func serialize() -> Dictionary:
return {
"type" :_type,
"line_number" :_line_number,
"message" :_message
}
func deserialize(serialized :Dictionary) -> GdUnitReport:
_type = serialized["type"]
_line_number = serialized["line_number"]
_message = serialized["message"]
return self

View file

@ -0,0 +1,36 @@
class_name GdUnitTestSuiteDefaultTemplate
extends RefCounted
const DEFAULT_TEMP_TS_GD ="""
# GdUnit generated TestSuite
class_name ${suite_class_name}
extends GdUnitTestSuite
@warning_ignore('unused_parameter')
@warning_ignore('return_value_discarded')
# TestSuite generated from
const __source = '${source_resource_path}'
"""
const DEFAULT_TEMP_TS_CS = """
// GdUnit generated TestSuite
using Godot;
using GdUnit3;
namespace ${name_space}
{
using static Assertions;
using static Utils;
[TestSuite]
public class ${suite_class_name}
{
// TestSuite generated from
private const string sourceClazzPath = "${source_resource_path}";
}
}
"""

View file

@ -0,0 +1,142 @@
class_name GdUnitTestSuiteTemplate
extends RefCounted
const TEMPLATE_ID_GD = 1000
const TEMPLATE_ID_CS = 2000
const SUPPORTED_TAGS_GD = """
GdScript Tags are replaced when the test-suite is created.
# The class name of the test-suite, formed from the source script.
${suite_class_name}
# is used to build the test suite class name
class_name ${suite_class_name}
extends GdUnitTestSuite
# The class name in pascal case, formed from the source script.
${source_class}
# can be used to create the class e.g. for source 'MyClass'
var my_test_class := ${source_class}.new()
# will be result in
var my_test_class := MyClass.new()
# The class as variable name in snake case, formed from the source script.
${source_var}
# Can be used to build the variable name e.g. for source 'MyClass'
var ${source_var} := ${source_class}.new()
# will be result in
var my_class := MyClass.new()
# The full resource path from which the file was created.
${source_resource_path}
# Can be used to load the script in your test
var my_script := load(${source_resource_path})
# will be result in
var my_script := load("res://folder/my_class.gd")
"""
const SUPPORTED_TAGS_CS = """
C# Tags are replaced when the test-suite is created.
// The namespace name of the test-suite
${name_space}
namespace ${name_space}
// The class name of the test-suite, formed from the source class.
${suite_class_name}
// is used to build the test suite class name
[TestSuite]
public class ${suite_class_name}
// The class name formed from the source class.
${source_class}
// can be used to create the class e.g. for source 'MyClass'
private string myTestClass = new ${source_class}();
// will be result in
private string myTestClass = new MyClass();
// The class as variable name in camelCase, formed from the source class.
${source_var}
// Can be used to build the variable name e.g. for source 'MyClass'
private object ${source_var} = new ${source_class}();
// will be result in
private object myClass = new MyClass();
// The full resource path from which the file was created.
${source_resource_path}
// Can be used to load the script in your test
private object myScript = GD.Load(${source_resource_path});
// will be result in
private object myScript = GD.Load("res://folder/MyClass.cs");
"""
const TAG_TEST_SUITE_CLASS = "${suite_class_name}"
const TAG_SOURCE_CLASS_NAME = "${source_class}"
const TAG_SOURCE_CLASS_VARNAME = "${source_var}"
const TAG_SOURCE_RESOURCE_PATH = "${source_resource_path}"
static func default_GD_template() -> String:
return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_GD.dedent().trim_prefix("\n")
static func default_CS_template() -> String:
return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_CS.dedent().trim_prefix("\n")
static func build_template(source_path: String) -> String:
var clazz_name :String = GdObjects.to_pascal_case(GdObjects.extract_class_name(source_path).value() as String)
return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())\
.replace(TAG_TEST_SUITE_CLASS, clazz_name+"Test")\
.replace(TAG_SOURCE_RESOURCE_PATH, source_path)\
.replace(TAG_SOURCE_CLASS_NAME, clazz_name)\
.replace(TAG_SOURCE_CLASS_VARNAME, GdObjects.to_snake_case(clazz_name))
static func default_template(template_id :int) -> String:
if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
return ""
if template_id == TEMPLATE_ID_GD:
return default_GD_template()
return default_CS_template()
static func load_template(template_id :int) -> String:
if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
return ""
if template_id == TEMPLATE_ID_GD:
return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())
return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template())
static func save_template(template_id :int, template :String) -> void:
if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
return
if template_id == TEMPLATE_ID_GD:
GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, template.dedent().trim_prefix("\n"))
elif template_id == TEMPLATE_ID_CS:
GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, template.dedent().trim_prefix("\n"))
static func reset_to_default(template_id :int) -> void:
if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
return
if template_id == TEMPLATE_ID_GD:
GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())
else:
GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template())
static func load_tags(template_id :int) -> String:
if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
return "Error checked loading tags"
if template_id == TEMPLATE_ID_GD:
return SUPPORTED_TAGS_GD
else:
return SUPPORTED_TAGS_CS

View file

@ -0,0 +1,62 @@
class_name GdUnitThreadContext
extends RefCounted
var _thread :Thread
var _thread_name :String
var _thread_id :int
var _assert :GdUnitAssert
var _signal_collector :GdUnitSignalCollector
var _execution_context :GdUnitExecutionContext
func _init(thread :Thread = null):
if thread != null:
_thread = thread
_thread_name = thread.get_meta("name")
_thread_id = thread.get_id() as int
else:
_thread_name = "main"
_thread_id = OS.get_main_thread_id()
_signal_collector = GdUnitSignalCollector.new()
func dispose() -> void:
_assert = null
if is_instance_valid(_signal_collector):
_signal_collector.clear()
_signal_collector = null
_execution_context = null
_thread = null
func set_assert(value :GdUnitAssert) -> GdUnitThreadContext:
_assert = value
return self
func get_assert() -> GdUnitAssert:
return _assert
func set_execution_context(context :GdUnitExecutionContext) -> void:
_execution_context = context
func get_execution_context() -> GdUnitExecutionContext:
return _execution_context
func get_execution_context_id() -> int:
return _execution_context.get_instance_id()
func get_signal_collector() -> GdUnitSignalCollector:
return _signal_collector
func thread_id() -> int:
return _thread_id
func _to_string() -> String:
return "ThreadContext <%s>: %s " % [_thread_name, _thread_id]

View file

@ -0,0 +1,62 @@
## A manager to run new thread and crate a ThreadContext shared over the actual test run
class_name GdUnitThreadManager
extends RefCounted
## { <thread_id> = <GdUnitThreadContext> }
var _thread_context_by_id := {}
## holds the current thread id
var _current_thread_id :int = -1
func _init():
# add initail the main thread
_current_thread_id = OS.get_thread_caller_id()
_thread_context_by_id[OS.get_main_thread_id()] = GdUnitThreadContext.new()
static func instance() -> GdUnitThreadManager:
return GdUnitSingleton.instance("GdUnitThreadManager", func(): return GdUnitThreadManager.new())
## Runs a new thread by given name and Callable.[br]
## A new GdUnitThreadContext is created, which is used for the actual test execution.[br]
## We need this custom implementation while this bug is not solved
## Godot issue https://github.com/godotengine/godot/issues/79637
static func run(name :String, cb :Callable) -> Variant:
return await instance()._run(name, cb)
## Returns the current valid thread context
static func get_current_context() -> GdUnitThreadContext:
return instance()._get_current_context()
func _run(name :String, cb :Callable):
# we do this hack because of `OS.get_thread_caller_id()` not returns the current id
# when await process_frame is called inside the fread
var save_current_thread_id = _current_thread_id
var thread := Thread.new()
thread.set_meta("name", name)
thread.start(cb)
_current_thread_id = thread.get_id() as int
_register_thread(thread, _current_thread_id)
var result :Variant = await thread.wait_to_finish()
_unregister_thread(_current_thread_id)
# restore original thread id
_current_thread_id = save_current_thread_id
return result
func _register_thread(thread :Thread, thread_id :int) -> void:
var context := GdUnitThreadContext.new(thread)
_thread_context_by_id[thread_id] = context
func _unregister_thread(thread_id :int) -> void:
var context := _thread_context_by_id.get(thread_id) as GdUnitThreadContext
if context:
_thread_context_by_id.erase(thread_id)
context.dispose()
func _get_current_context() -> GdUnitThreadContext:
return _thread_context_by_id.get(_current_thread_id)