reinstalling GDUnit from assetlib
Some checks failed
Create tag and build when new code gets to main / Export (push) Failing after 6m41s

This commit is contained in:
2026-01-26 09:05:55 +01:00
parent 4095f818f6
commit bdce8b969c
438 changed files with 22833 additions and 17 deletions

View File

@@ -0,0 +1,127 @@
## 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_VECTOR4_ARRAY,
TYPE_PACKED_COLOR_ARRAY
]
static func is_array_type(value: Variant) -> 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: Variant, value: Variant) -> Variant:
if not is_array_type(array):
return null
@warning_ignore("unsafe_method_access")
var filtered_array: Variant = array.duplicate()
@warning_ignore("unsafe_method_access")
var index: int = filtered_array.find(value)
while index != -1:
@warning_ignore("unsafe_method_access")
filtered_array.remove_at(index)
@warning_ignore("unsafe_method_access")
index = filtered_array.find(value)
return filtered_array
## Groups an array by a custom key selector
## The function should take an item and return the group key
static func group_by(array: Array, key_selector: Callable) -> Dictionary:
var result := {}
for item: Variant in array:
var group_key: Variant = key_selector.call(item)
var values: Array = result.get_or_add(group_key, [])
values.append(item)
return result
## Erases a value from given array by using equals(l,r) to find the element to erase
static func erase_value(array :Array, value :Variant) -> void:
for element :Variant 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 :Variant 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:
var delemiter := ", "
if elements == null:
return "<null>"
@warning_ignore("unsafe_cast")
if (elements as Array).is_empty():
return "<empty>"
var prefix := _typeof_as_string(elements) if encode_value else ""
var formatted := ""
var index := 0
for element :Variant 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 has_same_content(current: Array, other: Array) -> bool:
if current.size() != other.size(): return false
for element: Variant in current:
if not other.has(element): return false
if current.count(element) != other.count(element): return false
return true
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 @@
uid://bk60ywsj4ekp7

View File

@@ -0,0 +1,224 @@
# Myers' Diff Algorithm implementation
# Based on "An O(ND) Difference Algorithm and Its Variations" by Eugene W. Myers
class_name GdDiffTool
extends RefCounted
const DIV_ADD :int = 214
const DIV_SUB :int = 215
class Edit:
enum Type { EQUAL, INSERT, DELETE }
var type: Type
var character: int
func _init(t: Type, chr: int) -> void:
type = t
character = chr
# Main entry point - returns [ldiff, rdiff]
static func string_diff(left: Variant, right: Variant) -> Array[PackedInt32Array]:
var lb := PackedInt32Array() if left == null else str(left).to_utf32_buffer().to_int32_array()
var rb := PackedInt32Array() if right == null else str(right).to_utf32_buffer().to_int32_array()
# Early exit for identical strings
if lb == rb:
return [lb.duplicate(), rb.duplicate()]
var edits := _myers_diff(lb, rb)
return _edits_to_diff_format(edits)
# Core Myers' algorithm
static func _myers_diff(a: PackedInt32Array, b: PackedInt32Array) -> Array[Edit]:
var n := a.size()
var m := b.size()
var max_d := n + m
# V array stores the furthest reaching x coordinate for each k-line
# We need indices from -max_d to max_d, so we offset by max_d
var v := PackedInt32Array()
v.resize(2 * max_d + 1)
v.fill(-1)
v[max_d + 1] = 0 # k=1 starts at x=0
var trace := [] # Store V arrays for each d to backtrack later
# Find the edit distance
for d in range(0, max_d + 1):
# Store current V for backtracking
trace.append(v.duplicate())
for k in range(-d, d + 1, 2):
var k_offset := k + max_d
# Decide whether to move down or right
var x: int
if k == -d or (k != d and v[k_offset - 1] < v[k_offset + 1]):
x = v[k_offset + 1] # Move down (insert from b)
else:
x = v[k_offset - 1] + 1 # Move right (delete from a)
var y := x - k
# Follow diagonal as far as possible (matching characters)
while x < n and y < m and a[x] == b[y]:
x += 1
y += 1
v[k_offset] = x
# Check if we've reached the end
if x >= n and y >= m:
return _backtrack(a, b, trace, d, max_d)
# Should never reach here for valid inputs
return []
# Backtrack through the edit graph to build the edit script
static func _backtrack(a: PackedInt32Array, b: PackedInt32Array, trace: Array, d: int, max_d: int) -> Array[Edit]:
var edits: Array[Edit] = []
var x := a.size()
var y := b.size()
# Walk backwards through each d value
for depth in range(d, -1, -1):
var v: PackedInt32Array = trace[depth]
var k := x - y
var k_offset := k + max_d
# Determine previous k
var prev_k: int
if k == -depth or (k != depth and v[k_offset - 1] < v[k_offset + 1]):
prev_k = k + 1
else:
prev_k = k - 1
var prev_k_offset := prev_k + max_d
var prev_x := v[prev_k_offset]
var prev_y := prev_x - prev_k
# Extract diagonal (equal) characters
while x > prev_x and y > prev_y:
x -= 1
y -= 1
#var char_array := PackedInt32Array([a[x]])
edits.insert(0, Edit.new(Edit.Type.EQUAL, a[x]))
# Record the edit operation
if depth > 0:
if x == prev_x:
# Insert from b
y -= 1
#var char_array := PackedInt32Array([b[y]])
edits.insert(0, Edit.new(Edit.Type.INSERT, b[y]))
else:
# Delete from a
x -= 1
#var char_array := PackedInt32Array([a[x]])
edits.insert(0, Edit.new(Edit.Type.DELETE, a[x]))
return edits
# Convert edit script to the DIV_ADD/DIV_SUB format
static func _edits_to_diff_format(edits: Array[Edit]) -> Array[PackedInt32Array]:
var ldiff := PackedInt32Array()
var rdiff := PackedInt32Array()
for edit in edits:
match edit.type:
Edit.Type.EQUAL:
ldiff.append(edit.character)
rdiff.append(edit.character)
Edit.Type.INSERT:
ldiff.append(DIV_ADD)
ldiff.append(edit.character)
rdiff.append(DIV_SUB)
rdiff.append(edit.character)
Edit.Type.DELETE:
ldiff.append(DIV_SUB)
ldiff.append(edit.character)
rdiff.append(DIV_ADD)
rdiff.append(edit.character)
return [ldiff, 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]:
@warning_ignore("return_value_discarded")
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 @@
uid://b5sli0lem5xca

View File

@@ -0,0 +1,726 @@
# 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")
# introduced with Godot 4.3.beta1
const TYPE_VOID = 1000
const TYPE_VARARG = 1001
const TYPE_VARIANT = 1002
const TYPE_FUNC = 1003
const TYPE_FUZZER = 1004
# missing Godot types
const TYPE_NODE = 2001
const TYPE_CONTROL = 2002
const TYPE_CANVAS = 2003
const TYPE_ENUM = 2004
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_VECTOR4_ARRAY: "PackedVector4Array",
TYPE_PACKED_COLOR_ARRAY: "PackedColorArray",
TYPE_VOID: "void",
TYPE_VARARG: "VarArg",
TYPE_FUNC: "Func",
TYPE_FUZZER: "Fuzzer",
TYPE_VARIANT: "Variant"
}
class EditorNotifications:
# NOTE: Hardcoding to avoid runtime errors in exported projects when editor
# classes are not available. These values are unlikely to change.
# See: EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED
const NOTIFICATION_EDITOR_SETTINGS_CHANGED := 10000
const NOTIFICATION_AS_STRING_MAPPINGS := {
TYPE_OBJECT: {
Object.NOTIFICATION_POSTINITIALIZE : "POSTINITIALIZE",
Object.NOTIFICATION_PREDELETE: "PREDELETE",
EditorNotifications.NOTIFICATION_EDITOR_SETTINGS_CHANGED: "EDITOR_SETTINGS_CHANGED",
},
TYPE_NODE: {
Node.NOTIFICATION_ENTER_TREE : "ENTER_TREE",
Node.NOTIFICATION_EXIT_TREE: "EXIT_TREE",
Node.NOTIFICATION_CHILD_ORDER_CHANGED: "CHILD_ORDER_CHANGED",
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 : {
Object.NOTIFICATION_PREDELETE: "PREDELETE",
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 script: Script = obj.get_script()
# handle build-in scripts
if script.resource_path != null and script.resource_path.contains(".tscn"):
var path_elements := script.resource_path.split(".tscn")
clazz_name = path_elements[0].get_file()
clazz_path = script.resource_path
else:
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 :String = property["name"]
var property_type :int = property["type"]
var property_value :Variant = obj.get(property_name)
if property_value is GDScript or property_value is Callable or property_value is RegEx:
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
@warning_ignore("unsafe_cast")
dict[property_name] = obj2dict(property_value as Object, hashed_objects)
else:
dict[property_name] = property_value
if obj is Node:
var childrens :Array = (obj as Node).get_children()
dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects))
if obj is TreeItem:
var childrens :Array = (obj as TreeItem).get_children()
dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects))
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[Variant], obj_b: Array[Variant], case_sensitive: bool = false, compare_mode: COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
var a: Array[Variant] = obj_a.duplicate()
var b: Array[Variant] = 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:
@warning_ignore("unsafe_cast")
return (obj_a as GdUnitArgumentMatcher).is_match(obj_b)
if is_instance_valid(obj_b) and obj_b is GdUnitArgumentMatcher:
@warning_ignore("unsafe_cast")
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
@warning_ignore("unsafe_method_access")
if obj_a.get_class() != obj_b.get_class():
return false
@warning_ignore("unsafe_cast")
var a := obj2dict(obj_a as Object)
@warning_ignore("unsafe_cast")
var b := obj2dict(obj_b as Object)
return _equals(a, b, case_sensitive, compare_mode, deep_stack, stack_depth)
return obj_a == obj_b
TYPE_ARRAY:
@warning_ignore("unsafe_method_access")
if obj_a.size() != obj_b.size():
return false
@warning_ignore("unsafe_method_access")
for index :int 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:
@warning_ignore("unsafe_method_access")
if obj_a.size() != obj_b.size():
return false
@warning_ignore("unsafe_method_access")
for key :Variant in obj_a.keys():
@warning_ignore("unsafe_method_access")
var value_a :Variant = obj_a[key] if obj_a.has(key) else null
@warning_ignore("unsafe_method_access")
var value_b :Variant = 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:
@warning_ignore("unsafe_method_access")
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 and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].has(notification):
return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].get(notification, error)
if instance is Control and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].has(notification):
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 :int 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(" ", "")
@warning_ignore("return_value_discarded")
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:
if type < TYPE_MAX:
return type_string(type)
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 :Variant) -> bool:
if value is GDScript or value is ScriptExtension:
return false
var obj: Object = value
if is_instance_valid(obj) and obj.has_method("is_class"):
return obj.is_class("GDScriptNativeClass")
return false
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
@warning_ignore("unsafe_cast")
if value is GDScript and (value as GDScript).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:
@warning_ignore("unsafe_cast")
return (left as Object).get_instance_id() == (right as Object).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_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:
@warning_ignore("unsafe_cast")
return value is String and (value as String).ends_with(".tscn")
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():
@warning_ignore("unsafe_cast")
if (value as Object).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
@warning_ignore("unsafe_cast")
if is_script(value) and (value as Script).get_instance_base_type() == "":
return true
if is_scene(value):
return true
@warning_ignore("unsafe_cast")
return not (value as Object).has_method('new') and not (value as Object).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: Node = instance
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
@warning_ignore("unsafe_cast")
return (obj as Object).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)
@warning_ignore("unsafe_method_access")
return GdUnitResult.success(clazz.new())
TYPE_STRING:
var clazz_name: String = clazz
if ClassDB.class_exists(clazz_name):
if Engine.has_singleton(clazz_name):
return GdUnitResult.error("Not allowed to create a instance for singelton '%s'." % clazz_name)
if not ClassDB.can_instantiate(clazz_name):
return GdUnitResult.error("Can't instance Engine class '%s'." % clazz_name)
return GdUnitResult.success(ClassDB.instantiate(clazz_name))
else:
var clazz_path :String = extract_class_path(clazz_name)[0]
if not FileAccess.file_exists(clazz_path):
return GdUnitResult.error("Class '%s' not found." % clazz_name)
var script: GDScript = load(clazz_path)
if script != null:
return GdUnitResult.success(script.new())
else:
return GdUnitResult.error("Can't create instance for '%s'." % clazz_name)
return GdUnitResult.error("Can't create instance for class '%s'." % str(clazz))
## We do dispose 'GDScriptFunctionState' in a kacky style because the class is not visible anymore
static func dispose_function_state(func_state: Variant) -> void:
if func_state != null and str(func_state).contains("GDScriptFunctionState"):
@warning_ignore("unsafe_method_access")
func_state.completed.emit()
@warning_ignore("return_value_discarded")
static func extract_class_path(clazz :Variant) -> PackedStringArray:
var clazz_path := PackedStringArray()
if clazz is String:
@warning_ignore("unsafe_cast")
clazz_path.append(clazz as String)
return clazz_path
if is_instance(clazz):
# is instance a script instance?
var script: GDScript = clazz.script
if script != null:
return extract_class_path(script)
return clazz_path
if clazz is GDScript:
var script: GDScript = clazz
if not script.resource_path.is_empty():
clazz_path.append(script.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(script, "_init")
var instance: Object = script.callv("new", arg_list)
var clazz_info := inst_to_dict(instance)
GdUnitTools.free_instance(instance)
@warning_ignore("unsafe_cast")
clazz_path.append(clazz_info["@path"] as String)
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: GDScript = clazz.script
if script != null:
return extract_class_name(script)
@warning_ignore("unsafe_cast")
return GdUnitResult.success((clazz as Object).get_class())
# extract name form full qualified class path
if clazz is String:
var clazz_name: String = clazz
if ClassDB.class_exists(clazz_name):
return GdUnitResult.success(clazz_name)
var source_script :GDScript = load(clazz_name)
clazz_name = GdScriptParser.new().get_class_name(source_script)
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):
@warning_ignore("unsafe_cast")
if (clazz as Script).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
@warning_ignore("unsafe_method_access")
var instance :Variant = clazz.new()
if instance == null:
return GdUnitResult.error("Can't create a instance for class '%s'" % str(clazz))
var result := extract_class_name(instance)
@warning_ignore("return_value_discarded")
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 :String in map.keys():
var value :Variant = map.get(key)
if value is GDScript:
var class_path := extract_class_path(value)
@warning_ignore("return_value_discarded")
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[Dictionary] = func_sig["args"]
for arg in args:
var value_type :int = arg["type"]
var default_value :Variant = 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) -> Variant:
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_CALLABLE: return Callable()
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 @@
uid://b7ldhc4ryfh1v

View File

@@ -0,0 +1,65 @@
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) -> void:
_major = major
_minor = minor
_patch = patch
static func parse(value :String) -> GdUnit4Version:
var regex := RegEx.new()
@warning_ignore("return_value_discarded")
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()
@warning_ignore("return_value_discarded")
config.load('addons/gdUnit4/plugin.cfg')
@warning_ignore("unsafe_cast")
return parse(config.get_value('plugin', 'version') as String)
func equals(other :GdUnit4Version) -> bool:
return _major == other._major and _minor == other._minor and _patch == other._patch
func is_greater(other :GdUnit4Version) -> 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()
@warning_ignore("return_value_discarded")
config.load('addons/gdUnit4/plugin.cfg')
var version :String = config.get_value('plugin', 'version')
if label is RichTextLabel:
(label as RichTextLabel).text = VERSION_PATTERN.replace('${version}', version)
else:
(label as Label).text = "gdUnit4 " + version
func _to_string() -> String:
return "v%d.%d.%d" % [_major, _minor, _patch]
func documentation_version() -> String:
return "v%d.%d.x" % [_major, _minor]

View File

@@ -0,0 +1 @@
uid://bbaqjhpbxce3u

View File

@@ -0,0 +1,232 @@
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):
@warning_ignore("return_value_discarded")
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):
@warning_ignore("return_value_discarded")
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:
@warning_ignore("return_value_discarded")
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:
@warning_ignore("return_value_discarded")
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.include_hidden = true
@warning_ignore("return_value_discarded")
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
@warning_ignore("return_value_discarded")
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
@warning_ignore("return_value_discarded")
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 as_resource_path(value: String) -> String:
if value.begins_with("res://"):
return value
return "res://" + value.trim_prefix("//").trim_prefix("/").trim_suffix("/")
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.include_hidden = true
@warning_ignore("return_value_discarded")
dir.list_dir_begin()
var next := "."
while next != "":
next = dir.get_next()
if next.is_empty() or next == "." or next == "..":
continue
@warning_ignore("return_value_discarded")
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():
@warning_ignore("return_value_discarded")
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 path.begins_with("res://"):
return path
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("/"):
@warning_ignore("return_value_discarded")
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))
@warning_ignore("return_value_discarded")
zip.close()
return GdUnitResult.success(dest_path)

View File

@@ -0,0 +1 @@
uid://dflqb5germp5n

View File

@@ -0,0 +1 @@
uid://cqndh0nuu8ltx

View File

@@ -0,0 +1 @@
uid://cnvq3nb61ei76

View File

@@ -0,0 +1,126 @@
class_name GdUnitRunnerConfig
extends Resource
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
const CONFIG_VERSION = "5.0"
const VERSION = "version"
const TESTS = "tests"
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
TESTS : Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase),
# the port of running test server for this session
SERVER_PORT : -1
}
func version() -> String:
return _config[VERSION]
func clear() -> GdUnitRunnerConfig:
_config[TESTS] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
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 add_test_cases(tests: Array[GdUnitTestCase]) -> GdUnitRunnerConfig:
test_cases().append_array(tests)
return self
func test_cases() -> Array[GdUnitTestCase]:
return _config.get(TESTS, [])
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)])
var to_save := {
VERSION : CONFIG_VERSION,
SERVER_PORT : _config.get(SERVER_PORT),
TESTS : Array()
}
var tests: Array = to_save.get(TESTS)
for test in test_cases():
tests.append(inst_to_dict(test))
file.store_string(JSON.stringify(to_save, "\t"))
return GdUnitResult.success(path)
func load_config(path: String = CONFIG_FILE) -> GdUnitResult:
if not FileAccess.file_exists(path):
return GdUnitResult.warn("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)
var config: Dictionary = test_json_conv.get_data()
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)
var default: Array[Dictionary] = Array([], TYPE_DICTIONARY, "", null)
var tests_as_json: Array = config.get(TESTS, default)
_config = config
_config[TESTS] = convert_test_json_to_test_cases(tests_as_json)
fix_value_types()
return GdUnitResult.success(path)
func convert_test_json_to_test_cases(jsons: Array) -> Array[GdUnitTestCase]:
if jsons.is_empty():
return []
var tests := jsons.map(func(d: Dictionary) -> GdUnitTestCase:
var test: GdUnitTestCase = dict_to_inst(d)
# we need o covert manually to the corect type becaus JSON do not handle typed values
test.guid = GdUnitGUID.new(str(d["guid"]))
test.attribute_index = test.attribute_index as int
test.line_number = test.line_number as int
return test
)
return Array(tests, TYPE_OBJECT, "RefCounted", GdUnitTestCase)
func fix_value_types() -> void:
# 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_
func convert_Array_to_PackedStringArray(data: Dictionary) -> void:
for key in data.keys() as Array[String]:
var values :Array = data[key]
data[key] = PackedStringArray(values)
func _to_string() -> String:
return str(_config)

View File

@@ -0,0 +1 @@
uid://ltvpkh3ayklf

View File

@@ -0,0 +1,622 @@
# This class provides a runner for scense to simulate interactions like keyboard or mouse
class_name GdUnitSceneRunnerImpl
extends GdUnitSceneRunner
var GdUnitFuncAssertImpl: GDScript = 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 _action_on_press := []
var _curent_mouse_position: Vector2
# holds the touch position for each touch index
# { index: int = position: Vector2}
var _current_touch_position: Dictionary = {}
# holds the curretn touch drag position
var _current_touch_drag_position: Vector2 = Vector2.ZERO
# time factor settings
var _time_factor := 1.0
var _saved_iterations_per_second: float
var _scene_auto_free := false
func _init(p_scene: Variant, p_verbose: bool, p_hide_push_errors := false) -> void:
_verbose = p_verbose
_saved_iterations_per_second = Engine.get_physics_ticks_per_second()
@warning_ignore("return_value_discarded")
set_time_factor(1)
# handle scene loading by resource path
if typeof(p_scene) == TYPE_STRING:
@warning_ignore("unsafe_cast")
if !ResourceLoader.exists(p_scene as String):
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: Can't load scene by given resource path: '%s'. The resource does not exists." % p_scene)
return
if !str(p_scene).ends_with(".tscn") and !str(p_scene).ends_with(".scn") and !str(p_scene).begins_with("uid://"):
if not p_hide_push_errors:
push_error("GdUnitSceneRunner: The given resource: '%s'. is not a scene." % p_scene)
return
@warning_ignore("unsafe_cast")
_current_scene = (load(p_scene as String) as PackedScene).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
@warning_ignore("return_value_discarded")
_scene_tree().root.child_exiting_tree.connect(func f(child :Node) -> void:
if child == _current_scene:
# we need to disable the processing to avoid input flush buffer errors
_current_scene.process_mode = Node.PROCESS_MODE_DISABLED
_reset_input_to_default()
)
_simulate_start_time = LocalTime.now()
# we need to set inital a valid window otherwise the warp_mouse() is not handled
move_window_to_foreground()
# 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: int) -> void:
if what == NOTIFICATION_PREDELETE and is_instance_valid(self):
# reset time factor to normal
__deactivate_time_factor()
if is_instance_valid(_current_scene):
move_window_to_background()
_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
func _scene_tree() -> SceneTree:
return Engine.get_main_loop() as SceneTree
func await_input_processed() -> void:
if scene() != null and scene().process_mode != Node.PROCESS_MODE_DISABLED:
Input.flush_buffered_events()
await (Engine.get_main_loop() as SceneTree).process_frame
await (Engine.get_main_loop() as SceneTree).physics_frame
@warning_ignore("return_value_discarded")
func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner:
simulate_action_press(action, event_index)
simulate_action_release(action, event_index)
return self
func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner:
__print_current_focus()
var event := InputEventAction.new()
event.pressed = true
event.action = action
event.event_index = event_index
_action_on_press.append(action)
return _handle_input_event(event)
func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner:
__print_current_focus()
var event := InputEventAction.new()
event.pressed = false
event.action = action
event.event_index = event_index
_action_on_press.erase(action)
return _handle_input_event(event)
@warning_ignore("return_value_discarded")
func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
simulate_key_press(key_code, shift_pressed, ctrl_pressed)
await _scene_tree().process_frame
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 as Key
event.physical_keycode = key_code as Key
event.unicode = 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 as Key
event.physical_keycode = key_code as Key
event.unicode = 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_position(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 as InputEventMouse).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() as SceneTree).root.get_mouse_position()
func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner:
var event := InputEventMouseMotion.new()
event.position = position
event.relative = position - get_mouse_position()
event.global_position = get_global_mouse_position()
_apply_input_mouse_mask(event)
_apply_input_modifiers(event)
return _handle_input_event(event)
@warning_ignore("return_value_discarded")
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
@warning_ignore("return_value_discarded")
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
@warning_ignore("return_value_discarded")
func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner:
simulate_mouse_button_press(button_index, double_click)
simulate_mouse_button_release(button_index)
return self
func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner:
var event := InputEventMouseButton.new()
event.button_index = button_index
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(button_index)
return _handle_input_event(event)
func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner:
var event := InputEventMouseButton.new()
event.button_index = button_index
event.pressed = false
_apply_input_mouse_position(event)
_apply_input_mouse_mask(event)
_apply_input_modifiers(event)
_mouse_button_on_press.erase(button_index)
return _handle_input_event(event)
@warning_ignore("return_value_discarded")
func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner:
simulate_screen_touch_press(index, position, double_tap)
simulate_screen_touch_release(index)
return self
@warning_ignore("return_value_discarded")
func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner:
if is_emulate_mouse_from_touch():
# we need to simulate in addition to the touch the mouse events
set_mouse_position(position)
simulate_mouse_button_press(MOUSE_BUTTON_LEFT)
# push touch press event at position
var event := InputEventScreenTouch.new()
event.window_id = scene().get_window().get_window_id()
event.index = index
event.position = position
event.double_tap = double_tap
event.pressed = true
_current_scene.get_viewport().push_input(event)
# save current drag position by index
_current_touch_position[index] = position
return self
@warning_ignore("return_value_discarded")
func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner:
if is_emulate_mouse_from_touch():
# we need to simulate in addition to the touch the mouse events
simulate_mouse_button_release(MOUSE_BUTTON_LEFT)
# push touch release event at position
var event := InputEventScreenTouch.new()
event.window_id = scene().get_window().get_window_id()
event.index = index
event.position = get_screen_touch_drag_position(index)
event.pressed = false
event.double_tap = (_last_input_event as InputEventScreenTouch).double_tap if _last_input_event is InputEventScreenTouch else double_tap
_current_scene.get_viewport().push_input(event)
return self
func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
var current_position: Vector2 = _current_touch_position[index]
return await _do_touch_drag_at(index, current_position + relative, time, trans_type)
func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
return await _do_touch_drag_at(index, position, time, trans_type)
@warning_ignore("return_value_discarded")
func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
simulate_screen_touch_press(index, position)
return await _do_touch_drag_at(index, drop_position, time, trans_type)
@warning_ignore("return_value_discarded")
func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner:
if is_emulate_mouse_from_touch():
simulate_mouse_move(position)
var event := InputEventScreenDrag.new()
event.window_id = scene().get_window().get_window_id()
event.index = index
event.position = position
event.relative = _get_screen_touch_drag_position_or_default(index, position) - position
event.velocity = event.relative / _scene_tree().root.get_process_delta_time()
event.pressure = 1.0
_current_touch_position[index] = position
_current_scene.get_viewport().push_input(event)
return self
func get_screen_touch_drag_position(index: int) -> Vector2:
if _current_touch_position.has(index):
return _current_touch_position[index]
push_error("No touch drag position for index '%d' is set!" % index)
return Vector2.ZERO
func is_emulate_mouse_from_touch() -> bool:
return ProjectSettings.get_setting("input_devices/pointing/emulate_mouse_from_touch", true)
func _get_screen_touch_drag_position_or_default(index: int, default_position: Vector2) -> Vector2:
if _current_touch_position.has(index):
return _current_touch_position[index]
return default_position
@warning_ignore("return_value_discarded")
func _do_touch_drag_at(index: int, drag_position: Vector2, time: float, trans_type: Tween.TransitionType) -> GdUnitSceneRunner:
# start draging
var event := InputEventScreenDrag.new()
event.window_id = scene().get_window().get_window_id()
event.index = index
event.position = get_screen_touch_drag_position(index)
event.pressure = 1.0
_current_touch_drag_position = event.position
var tween := _scene_tree().create_tween()
tween.tween_property(self, "_current_touch_drag_position", drag_position, time).set_trans(trans_type)
tween.play()
while not _current_touch_drag_position.is_equal_approx(drag_position):
if is_emulate_mouse_from_touch():
# we need to simulate in addition to the drag the mouse move events
simulate_mouse_move(event.position)
# send touche drag event to new position
event.relative = _current_touch_drag_position - event.position
event.velocity = event.relative / _scene_tree().root.get_process_delta_time()
event.position = _current_touch_drag_position
_current_scene.get_viewport().push_input(event)
await _scene_tree().process_frame
# finaly drop it
if is_emulate_mouse_from_touch():
simulate_mouse_move(drag_position)
simulate_mouse_button_release(MOUSE_BUTTON_LEFT)
var touch_drop_event := InputEventScreenTouch.new()
touch_drop_event.window_id = event.window_id
touch_drop_event.index = event.index
touch_drop_event.position = drag_position
touch_drop_event.pressed = false
_current_scene.get_viewport().push_input(touch_drop_event)
await _scene_tree().process_frame
return self
func set_time_factor(time_factor: float = 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, ...args: Array) -> GdUnitSceneRunner:
await _awaiter.await_signal_idle_frames(scene(), signal_name, args, 10000)
return self
func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner:
await _awaiter.await_signal_idle_frames(source, signal_name, args, 10000)
return self
func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert:
return GdUnitFuncAssertImpl.new(scene(), func_name, args)
func await_func_on(instance: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert:
return GdUnitFuncAssertImpl.new(instance, func_name, args)
func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void:
await _awaiter.await_signal_on(scene(), signal_name, args, timeout)
func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void:
await _awaiter.await_signal_on(source, signal_name, args, timeout)
func move_window_to_foreground() -> GdUnitSceneRunner:
if not Engine.is_embedded_in_editor():
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_move_to_foreground()
return self
func move_window_to_background() -> GdUnitSceneRunner:
if not Engine.is_embedded_in_editor():
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
return self
func _property_exists(name: String) -> bool:
return scene().get_property_list().any(func(properties :Dictionary) -> bool: 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, ...args: Array) -> Variant:
if scene().has_method(name):
return await 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:
var last_input_event := _last_input_event as InputEventWithModifiers
var _event := event as 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 as InputEventMouse).button_mask |= (_last_input_event as InputEventMouse).button_mask
if event is InputEventMouseButton:
var _event := event as InputEventMouseButton
var button_mask :int = 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 as InputEventMouseButton).position = (_last_input_event as InputEventMouse).position
## handle input action via Input modifieres
func _handle_actions(event: InputEventAction) -> bool:
if not InputMap.event_is_action(event, event.action, true):
return false
__print(" process action %s (%s) <- %s" % [scene(), _scene_name(), event.as_text()])
if event.is_pressed():
Input.action_press(event.action, event.get_strength())
else:
Input.action_release(event.action)
return true
# for handling read https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html?highlight=inputevent#how-does-it-work
@warning_ignore("return_value_discarded")
func _handle_input_event(event: InputEvent) -> GdUnitSceneRunner:
if event is InputEventMouse:
Input.warp_mouse((event as InputEventMouse).position as Vector2)
Input.parse_input_event(event)
if event is InputEventAction:
_handle_actions(event as InputEventAction)
var current_scene := scene()
if is_instance_valid(current_scene):
# do not flush events if node processing disabled otherwise we run into errors at tree removed
if _current_scene.process_mode != Node.PROCESS_MODE_DISABLED:
Input.flush_buffered_events()
__print(" process event %s (%s) <- %s" % [current_scene, _scene_name(), event.as_text()])
if(current_scene.has_method("_gui_input")):
(current_scene as Control)._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
@warning_ignore("return_value_discarded")
func _reset_input_to_default() -> void:
# reset all mouse button to inital state if need
for m_button :int 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 :int in _key_on_press.duplicate():
if Input.is_key_pressed(key_scancode):
simulate_key_release(key_scancode)
_key_on_press.clear()
for action :String in _action_on_press.duplicate():
if Input.is_action_pressed(action):
simulate_action_release(action)
_action_on_press.clear()
if is_instance_valid(_current_scene) and _current_scene.process_mode != Node.PROCESS_MODE_DISABLED:
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 @@
uid://7a566a4kfreu

View File

@@ -0,0 +1,435 @@
@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_HOOKS = MAIN_CATEGORY + "/hooks"
const SESSION_HOOKS = GROUP_HOOKS + "/session_hooks"
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_SUITE_NAMING_CONVENTION = GROUP_TEST + "/test_suite_naming_convention"
const TEST_DISCOVER_ENABLED = GROUP_TEST + "/test_discovery"
const TEST_FLAKY_CHECK = GROUP_TEST + "/flaky_check_enable"
const TEST_FLAKY_MAX_RETRIES = GROUP_TEST + "/flaky_max_retries"
# 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"
const INSPECTOR_TREE_VIEW_MODE = GROUP_UI_INSPECTOR + "/tree_view_mode"
const INSPECTOR_TREE_SORT_MODE = GROUP_UI_INSPECTOR + "/tree_sort_mode"
# 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"
# Feature flags
const GROUP_FEATURE = MAIN_CATEGORY + "/feature"
# 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 := "Subfolder where test suites are located (or empty to use source folder directly)"
enum NAMING_CONVENTIONS {
AUTO_DETECT,
SNAKE_CASE,
PASCAL_CASE,
}
const _VALUE_SET_SEPARATOR = "\f" # ASCII Form-feed character (AKA page break)
static func setup() -> void:
create_property_if_need(UPDATE_NOTIFICATION_ENABLED, true, "Show notification if new gdUnit4 version is found")
# test settings
create_property_if_need(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT, "Server connection timeout in minutes")
create_property_if_need(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT, "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_SUITE_NAMING_CONVENTION, NAMING_CONVENTIONS.AUTO_DETECT, "Naming convention to use when generating testsuites", NAMING_CONVENTIONS.keys())
create_property_if_need(TEST_DISCOVER_ENABLED, false, "Automatically detect new tests in test lookup folders at runtime")
create_property_if_need(TEST_FLAKY_CHECK, false, "Rerun tests on failure and mark them as FLAKY")
create_property_if_need(TEST_FLAKY_MAX_RETRIES, 3, "Sets the number of retries for rerunning a flaky test")
# report settings
create_property_if_need(REPORT_PUSH_ERRORS, false, "Report push_error() as failure")
create_property_if_need(REPORT_SCRIPT_ERRORS, true, "Report script errors as failure")
create_property_if_need(REPORT_ORPHANS, true, "Report orphaned nodes after tests finish")
create_property_if_need(REPORT_ASSERT_ERRORS, true, "Report assertion failures as errors")
create_property_if_need(REPORT_ASSERT_WARNINGS, true, "Report assertion failures as warnings")
create_property_if_need(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true, "Compare number values strictly by type (real vs int)")
# inspector
create_property_if_need(INSPECTOR_NODE_COLLAPSE, true,
"Close testsuite node after a successful test run.")
create_property_if_need(INSPECTOR_TREE_VIEW_MODE, GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE,
"Inspector panel presentation mode", GdUnitInspectorTreeConstants.TREE_VIEW_MODE.keys())
create_property_if_need(INSPECTOR_TREE_SORT_MODE, GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED,
"Inspector panel sorting mode", GdUnitInspectorTreeConstants.SORT_MODE.keys())
create_property_if_need(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, false,
"Show 'Run overall Tests' button in the inspector toolbar")
create_property_if_need(TEMPLATE_TS_GD, GdUnitTestSuiteTemplate.default_GD_template(), "Test suite template to use")
create_shortcut_properties_if_need()
create_property_if_need(SESSION_HOOKS, {} as Dictionary[String,bool])
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 :Variant) -> String: 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 the most recently executed tests")
create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG), "Rerun the most recently executed tests (Debug mode)")
create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL), "Runs all tests (Debug mode)")
create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_STOP, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.STOP_TEST_RUN), "Stop the current test execution")
# script editor
create_property_if_need(SHORTCUT_EDITOR_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE), "Run the currently selected test")
create_property_if_need(SHORTCUT_EDITOR_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG), "Run the currently selected test (Debug mode).")
create_property_if_need(SHORTCUT_EDITOR_CREATE_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.CREATE_TEST), "Create a new test case for the currently selected function")
# filesystem
create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.NONE), "Run all test suites in the selected folder or file")
create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.NONE), "Run all test suites in 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 = help if value_set.is_empty() else "%s%s%s" % [help, _VALUE_SET_SEPARATOR, 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)
@warning_ignore("return_value_discarded")
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)
@warning_ignore("return_value_discarded")
ProjectSettings.save()
static func get_session_hooks() -> Dictionary[String, bool]:
var property := get_property(SESSION_HOOKS)
if property == null:
return {}
var hooks: Dictionary[String, bool] = property.value()
return hooks
static func set_session_hooks(hooks: Dictionary[String, bool]) -> void:
var property := get_property(SESSION_HOOKS)
property.set_value(hooks)
update_property(property)
static func set_inspector_tree_sort_mode(sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void:
var property := get_property(INSPECTOR_TREE_SORT_MODE)
property.set_value(sort_mode)
update_property(property)
static func get_inspector_tree_sort_mode() -> GdUnitInspectorTreeConstants.SORT_MODE:
var property := get_property(INSPECTOR_TREE_SORT_MODE)
return property.value() if property != null else GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED
static func set_inspector_tree_view_mode(tree_view_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void:
var property := get_property(INSPECTOR_TREE_VIEW_MODE)
property.set_value(tree_view_mode)
update_property(property)
static func get_inspector_tree_view_mode() -> GdUnitInspectorTreeConstants.TREE_VIEW_MODE:
var property := get_property(INSPECTOR_TREE_VIEW_MODE)
return property.value() if property != null else GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE
# 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_test_discover_enabled() -> bool:
return get_setting(TEST_DISCOVER_ENABLED, false)
static func is_test_flaky_check_enabled() -> bool:
return get_setting(TEST_FLAKY_CHECK, false)
static func is_feature_enabled(feature: String) -> bool:
return get_setting(feature, false)
static func get_flaky_max_retries() -> int:
return get_setting(TEST_FLAKY_MAX_RETRIES, 3)
static func set_test_discover_enabled(enable :bool) -> void:
var property := get_property(TEST_DISCOVER_ENABLED)
property.set_value(enable)
update_property(property)
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):
settings.append(build_property(property_name, property))
return settings
static func extract_value_set_from_help(value :String) -> PackedStringArray:
var split_value := value.split(_VALUE_SET_SEPARATOR)
if not split_value.size() > 1:
return PackedStringArray()
var regex := RegEx.new()
@warning_ignore("return_value_discarded")
regex.compile("\\[(.+)\\]")
var matches := regex.search_all(split_value[1])
if matches.is_empty():
return PackedStringArray()
var values: String = matches[0].get_string(1)
return values.replacen(" ", "").replacen("\"", "").split(",", false)
static func extract_help_text(value :String) -> String:
return value.split(_VALUE_SET_SEPARATOR)[0]
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_as_string())
_: 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:
return build_property(name, property)
return null
static func build_property(property_name: String, property: Dictionary) -> GdUnitProperty:
var value: Variant = ProjectSettings.get_setting(property_name)
var value_type: int = property["type"]
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, value_type, value, default, extract_help_text(help), value_set)
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("Successfully migrated property '%s' -> '%s' value: %s" % [old_property, new_property, value])
static func dump_to_tmp() -> void:
@warning_ignore("return_value_discarded")
ProjectSettings.save_custom("user://project_settings.godot")
static func restore_dump_from_tmp() -> void:
@warning_ignore("return_value_discarded")
DirAccess.copy_absolute("user://project_settings.godot", "res://project.godot")

View File

@@ -0,0 +1 @@
uid://coby4unvmd3eh

View File

@@ -0,0 +1,81 @@
class_name GdUnitSignalAwaiter
extends RefCounted
signal signal_emitted(action :Variant)
const NO_ARG :Variant = GdUnitConstants.NO_ARG
var _wait_on_idle_frame := false
var _interrupted := false
var _time_left :float = 0
var _timeout_millis :int
func _init(timeout_millis :int, wait_on_idle_frame := false) -> void:
_timeout_millis = timeout_millis
_wait_on_idle_frame = wait_on_idle_frame
func _on_signal_emmited(
arg0 :Variant = NO_ARG,
arg1 :Variant = NO_ARG,
arg2 :Variant = NO_ARG,
arg3 :Variant = NO_ARG,
arg4 :Variant = NO_ARG,
arg5 :Variant = NO_ARG,
arg6 :Variant = NO_ARG,
arg7 :Variant = NO_ARG,
arg8 :Variant = NO_ARG,
arg9 :Variant = NO_ARG) -> void:
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() -> float:
return _time_left
func on_signal(source :Object, signal_name :String, expected_signal_args :Array) -> Variant:
# register checked signal to wait for
@warning_ignore("return_value_discarded")
source.connect(signal_name, _on_signal_emmited)
# install timeout timer
var scene_tree := Engine.get_main_loop() as SceneTree
var timer := Timer.new()
scene_tree.root.add_child(timer)
timer.add_to_group("GdUnitTimers")
timer.set_one_shot(true)
@warning_ignore("return_value_discarded")
timer.timeout.connect(_do_interrupt, 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 scene_tree.process_frame
source.disconnect(signal_name, _on_signal_emmited)
_time_left = timer.time_left
timer.queue_free()
await scene_tree.process_frame
@warning_ignore("unsafe_cast")
if value is Array and (value as Array).size() == 1:
return value[0]
return value
func _do_interrupt() -> void:
_interrupted = true
signal_emitted.emit(null)

View File

@@ -0,0 +1 @@
uid://ckx5jnr3ip6vp

View File

@@ -0,0 +1,129 @@
# 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 :Object 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, force_recreate := false) -> void:
if is_instance_valid(emitter):
# check emitter is already registerd
if _collected_signals.has(emitter):
if not force_recreate:
return
# If the flag recreate is set to true, emitters that are already registered must be deregistered before recreating,
# otherwise signals that have already been collected will be evaluated.
unregister_emitter(emitter)
_collected_signals[emitter] = Dictionary()
# connect to 'tree_exiting' of the emitter to finally release all acquired resources/connections.
if emitter is Node and !(emitter as Node).tree_exiting.is_connected(unregister_emitter):
(emitter as Node).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 :String = 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) -> void:
if is_instance_valid(emitter):
for signal_def in emitter.get_signal_list():
var signal_name :String = signal_def["name"]
if emitter.is_connected(signal_name, _on_signal_emmited):
emitter.disconnect(signal_name, _on_signal_emmited.bind(emitter, signal_name))
@warning_ignore("return_value_discarded")
_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 :Variant= NO_ARG,
arg1 :Variant= NO_ARG,
arg2 :Variant= NO_ARG,
arg3 :Variant= NO_ARG,
arg4 :Variant= NO_ARG,
arg5 :Variant= NO_ARG,
arg6 :Variant= NO_ARG,
arg7 :Variant= NO_ARG,
arg8 :Variant= NO_ARG,
arg9 :Variant= NO_ARG,
arg10 :Variant= NO_ARG,
arg11 :Variant= NO_ARG) -> void:
var signal_args :Array = 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):
@warning_ignore("unsafe_cast")
(_collected_signals[emitter][signal_name] as Array).append(signal_args)
func reset_received_signals(emitter: Object, signal_name: String, signal_args: Array) -> void:
#_debug_signal_list("before claer");
if _collected_signals.has(emitter):
var signals_by_emitter :Dictionary = _collected_signals[emitter]
if signals_by_emitter.has(signal_name):
var received_args: Array = _collected_signals[emitter][signal_name]
# We iterate backwarts over to received_args to remove matching args.
# This will avoid array corruption see comment on `erase` otherwise we need a timeconsuming duplicate before
for arg_pos: int in range(received_args.size()-1, -1, -1):
var arg: Variant = received_args[arg_pos]
if GdObjects.equals(arg, signal_args):
received_args.remove_at(arg_pos)
#_debug_signal_list("after claer");
func is_signal_collecting(emitter: Object, signal_name: String) -> bool:
@warning_ignore("unsafe_cast")
return _collected_signals.has(emitter) and (_collected_signals[emitter] as Dictionary).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 :Variant 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) -> void:
prints("-----", message, "-------")
prints("senders {")
for emitter :Object in _collected_signals:
prints("\t", emitter)
for signal_name :String in _collected_signals[emitter]:
var args :Variant = _collected_signals[emitter][signal_name]
prints("\t\t", signal_name, args)
prints("}")

View File

@@ -0,0 +1 @@
uid://cm0rbs8vhdhd1

View File

@@ -0,0 +1,118 @@
class_name GdUnitSignals
extends RefCounted
## Singleton class that handles GdUnit's signal communication.[br]
## [br]
## This class manages all signals used to communicate test events, discovery, and status changes.[br]
## It uses a singleton pattern stored in Engine metadata to ensure a single instance.[br]
## [br]
## Signals are grouped by purpose:[br]
## - Client connection handling[br]
## - Test execution events[br]
## - Test discovery events[br]
## - Settings and status updates[br]
## [br]
## Example usage:[br]
## [codeblock]
## # Connect to test discovery
## GdUnitSignals.instance().gdunit_test_discovered.connect(self._on_test_discovered)
##
## # Emit test event
## GdUnitSignals.instance().gdunit_event.emit(test_event)
## [/codeblock]
## Emitted when a client connects to the GdUnit server.[br]
## [param client_id] The ID of the connected client.
@warning_ignore("unused_signal")
signal gdunit_client_connected(client_id: int)
## Emitted when a client disconnects from the GdUnit server.[br]
## [param client_id] The ID of the disconnected client.
@warning_ignore("unused_signal")
signal gdunit_client_disconnected(client_id: int)
## Emitted when a client terminates unexpectedly.
@warning_ignore("unused_signal")
signal gdunit_client_terminated()
## Emitted when a test execution event occurs.[br]
## [param event] The test event containing details about test execution.
@warning_ignore("unused_signal")
signal gdunit_event(event: GdUnitEvent)
## Emitted for test debug events during execution.[br]
## [param event] The debug event containing test execution details.
@warning_ignore("unused_signal")
signal gdunit_event_debug(event: GdUnitEvent)
## Emitted to broadcast a general message.[br]
## [param message] The message to broadcast.
@warning_ignore("unused_signal")
signal gdunit_message(message: String)
## Emitted to update test failure status.[br]
## [param is_failed] Whether the test has failed.
@warning_ignore("unused_signal")
signal gdunit_set_test_failed(is_failed: bool)
## Emitted when a GdUnit setting changes.[br]
## [param property] The property that was changed.
@warning_ignore("unused_signal")
signal gdunit_settings_changed(property: GdUnitProperty)
## Called when a new test case is discovered during the discovery process.
## Custom implementations should connect to this signal and store the discovered test case as needed.[br]
## [param test_case] The discovered test case instance to be processed.
@warning_ignore("unused_signal")
signal gdunit_test_discover_added(test_case: GdUnitTestCase)
## Emitted when a test case is deleted.[br]
## [param test_case] The test case that was deleted.
@warning_ignore("unused_signal")
signal gdunit_test_discover_deleted(test_case: GdUnitTestCase)
## Emitted when a test case is modified.[br]
## [param test_case] The test case that was modified.
@warning_ignore("unused_signal")
signal gdunit_test_discover_modified(test_case: GdUnitTestCase)
const META_KEY := "GdUnitSignals"
## Returns the singleton instance of GdUnitSignals.[br]
## Creates a new instance if none exists.[br]
## [br]
## Returns: The GdUnitSignals singleton instance.
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_
## Cleans up the singleton instance and disconnects all signals.[br]
## [br]
## Should be called when GdUnit is shutting down or needs to reset.[br]
## Ensures proper cleanup of signal connections and resources.
static func dispose() -> void:
var signals := instance()
# cleanup connected signals
for signal_ in signals.get_signal_list():
@warning_ignore("unsafe_cast")
for connection in signals.get_signal_connection_list(signal_["name"] as StringName):
var _signal: Signal = connection["signal"]
var _callable: Callable = connection["callable"]
_signal.disconnect(_callable)
signals = null
Engine.remove_meta(META_KEY)

View File

@@ -0,0 +1 @@
uid://kj16fg0hf6kn

View File

@@ -0,0 +1,56 @@
################################################################################
# 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 Object
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()
if is_instance_of(singleton, RefCounted):
@warning_ignore("unsafe_cast")
push_error("Invalid singleton implementation detected for '%s' is `%s`!" % [name, (singleton as RefCounted).get_class()])
return
Engine.set_meta(name, singleton)
GdUnitTools.prints_verbose("Register singleton '%s:%s'" % [name, singleton])
var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
@warning_ignore("return_value_discarded")
singletons.append(name)
Engine.set_meta(MEATA_KEY, singletons)
return singleton
static func unregister(p_singleton: String, use_call_deferred: bool = false) -> 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_: Object = Engine.get_meta(p_singleton)
GdUnitTools.prints_verbose(" Free singleton instance '%s:%s'" % [p_singleton, instance_])
@warning_ignore("return_value_discarded")
GdUnitTools.free_instance(instance_, use_call_deferred)
Engine.remove_meta(p_singleton)
GdUnitTools.prints_verbose(" Successfully freed '%s'" % p_singleton)
Engine.set_meta(MEATA_KEY, singletons)
static func dispose(use_call_deferred: bool = false) -> 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 PackedStringArray(singletons):
unregister(singleton, use_call_deferred)
Engine.remove_meta(MEATA_KEY)
GdUnitTools.prints_verbose("----------------------------------------------------------------")

View File

@@ -0,0 +1 @@
uid://4sujouo3vf6d

View File

@@ -0,0 +1 @@
uid://ierjyaem56m3

View File

@@ -0,0 +1,20 @@
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
@warning_ignore("return_value_discarded")
ScriptEditorControls.save_an_open_script(source.resource_path)
@warning_ignore("return_value_discarded")
ScriptEditorControls.save_an_open_script(test_suite_path, true)
if source.get_class() == "CSharpScript":
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 @@
uid://dthfh16tl5wqc

View File

@@ -0,0 +1 @@
uid://bju0nt1bgsc2s

View File

@@ -0,0 +1,144 @@
extends RefCounted
static var _richtext_normalize: RegEx
static func normalize_text(text :String) -> String:
return text.replace("\r", "");
static func richtext_normalize(input :String) -> String:
if _richtext_normalize == null:
_richtext_normalize = to_regex("\\[/?(b|color|bgcolor|right|table|cell).*?\\]")
return _richtext_normalize.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, use_call_deferred :bool = false, is_stdout_verbose := false) -> bool:
if instance is Array:
var as_array: Array = instance
for element: Variant in as_array:
@warning_ignore("return_value_discarded")
free_instance(element)
as_array.clear()
return true
# do not free an already freed instance
if not is_instance_valid(instance):
return false
# do not free a class refernece
@warning_ignore("unsafe_cast")
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)
@warning_ignore("unsafe_cast")
release_double(instance as Object)
if instance is RefCounted:
@warning_ignore("unsafe_cast")
(instance as RefCounted).notification(Object.NOTIFICATION_PREDELETE)
# If scene runner freed we explicit await all inputs are processed
if instance is GdUnitSceneRunnerImpl:
@warning_ignore("unsafe_cast")
await (instance as GdUnitSceneRunnerImpl).await_input_processed()
return true
else:
if instance is Timer:
var timer: Timer = instance
timer.stop()
if use_call_deferred:
timer.call_deferred("free")
else:
timer.free()
await (Engine.get_main_loop() as SceneTree).process_frame
return true
@warning_ignore("unsafe_cast")
if instance is Node and (instance as Node).get_parent() != null:
var node: Node = instance
if is_stdout_verbose:
print_verbose("GdUnit4:gc():remove node from parent ", node.get_parent(), node)
if use_call_deferred:
node.get_parent().remove_child.call_deferred(node)
#instance.call_deferred("set_owner", null)
else:
node.get_parent().remove_child(node)
if is_stdout_verbose:
print_verbose("GdUnit4:gc():freeing `free()` the instance ", instance)
if use_call_deferred:
@warning_ignore("unsafe_cast")
(instance as Object).call_deferred("free")
else:
@warning_ignore("unsafe_cast")
(instance as Object).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'
var scene_tree := Engine.get_main_loop() as SceneTree
if scene_tree.root == null:
return
for node :Node in scene_tree.root.get_children():
if is_instance_valid(node) and node.is_in_group("GdUnitTimers"):
if is_instance_valid(node):
scene_tree.root.remove_child.call_deferred(node)
(node as Timer).stop()
node.queue_free()
# the finally cleaup unfreed resources and singletons
static func dispose_all(use_call_deferred :bool = false) -> void:
release_timers()
GdUnitSingleton.dispose(use_call_deferred)
GdUnitSignals.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 find_test_case(test_suite: Node, test_case_name: String, index := -1) -> _TestCase:
for test_case: _TestCase in test_suite.get_children():
if test_case.test_name() == test_case_name:
if index != -1:
if test_case._test_case.attribute_index != index:
continue
return test_case
return null
static func register_expect_interupted_by_timeout(test_suite: Node, test_case_name: String) -> void:
var test_case := find_test_case(test_suite, test_case_name)
if test_case:
test_case.expect_to_interupt()

View File

@@ -0,0 +1 @@
uid://d05qgv6uu477i

View File

@@ -0,0 +1,11 @@
## This service class contains helpers to wrap Godot functions and handle them carefully depending on the current Godot version
class_name GodotVersionFixures
extends RefCounted
# handle global_position fixed by https://github.com/godotengine/godot/pull/88473
static func set_event_global_position(event: InputEventMouseMotion, global_position: Vector2) -> void:
if Engine.get_version_info().hex >= 0x40202 or Engine.get_version_info().hex == 0x40104:
event.global_position = event.position
else:
event.global_position = global_position

View File

@@ -0,0 +1 @@
uid://dehxycxsj68ev

View File

@@ -0,0 +1,114 @@
# This class provides Date/Time functionallity to Godot
class_name LocalTime
extends Resource
enum TimeUnit {
DEFAULT = 0,
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
@warning_ignore("return_value_discarded")
_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
# create from epoch timestamp in ms
func _init(time: int) -> void:
_time = time
@warning_ignore("integer_division")
_hour = (time / MILLIS_PER_HOUR) % 24
@warning_ignore("integer_division")
_minute = (time / MILLIS_PER_MINUTE) % 60
@warning_ignore("integer_division")
_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 @@
uid://dmta1h7ndfnko

View File

@@ -0,0 +1,243 @@
class_name _TestCase
extends Node
signal completed()
var _test_case: GdUnitTestCase
var _attribute: TestCaseAttribute
var _current_iteration: int = -1
var _expect_to_interupt := false
var _timer: Timer
var _interupted: bool = false
var _failed := false
var _parameter_set_resolver: GdUnitTestParameterSetResolver
var _is_disposed := false
var _func_state: Variant
func _init(test_case: GdUnitTestCase, attribute: TestCaseAttribute, fd: GdFunctionDescriptor) -> void:
_test_case = test_case
_attribute = attribute
set_function_descriptor(fd)
func execute(p_test_parameter := Array(), p_iteration := 0) -> void:
_failure_received(false)
_current_iteration = p_iteration - 1
if _current_iteration == - 1:
_set_failure_handler()
set_timeout()
if is_parameterized():
execute_parameterized()
elif not p_test_parameter.is_empty():
update_fuzzers(p_test_parameter, p_iteration)
_execute_test_case(test_name(), p_test_parameter)
else:
_execute_test_case(test_name(), [])
await completed
func execute_parameterized() -> void:
_failure_received(false)
set_timeout()
# Resolve parameter set at runtime to include runtime variables
var test_parameters := await _resolve_test_parameters(_test_case.attribute_index)
if test_parameters.is_empty():
return
await _execute_test_case(test_name(), test_parameters)
func _resolve_test_parameters(attribute_index: int) -> Array:
var result := _parameter_set_resolver.load_parameter_sets(get_parent())
if result.is_error():
do_skip(true, result.error_message())
await (Engine.get_main_loop() as SceneTree).process_frame
completed.emit()
return []
# validate the parameter set
var parameter_sets: Array = result.value()
result = _parameter_set_resolver.validate(parameter_sets, attribute_index)
if result.is_error():
do_skip(true, result.error_message())
await (Engine.get_main_loop() as SceneTree).process_frame
completed.emit()
return []
@warning_ignore("unsafe_method_access")
var test_parameters: Array = parameter_sets[attribute_index].duplicate()
# 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.
test_parameters.append([])
return test_parameters
func dispose() -> void:
if _is_disposed:
return
_is_disposed = true
Engine.remove_meta("GD_TEST_FAILURE")
stop_timer()
_remove_failure_handler()
_attribute.fuzzers.clear()
@warning_ignore("shadowed_variable_base_class", "redundant_await")
func _execute_test_case(name: String, test_parameter: Array) -> void:
# save the function state like GDScriptFunctionState to dispose at test timeout to prevent orphan state
_func_state = get_parent().callv(name, test_parameter)
await _func_state
# needs at least on await otherwise it breaks the awaiting chain
await (Engine.get_main_loop() as SceneTree).process_frame
completed.emit()
func update_fuzzers(input_values: Array, iteration: int) -> void:
for fuzzer :Variant in input_values:
if fuzzer is Fuzzer:
fuzzer._iteration_index = iteration + 1
func set_timeout() -> void:
if is_instance_valid(_timer):
return
var time: float = _attribute.timeout / 1000.0
_timer = Timer.new()
add_child(_timer)
_timer.set_name("gdunit_test_case_timer_%d" % _timer.get_instance_id())
@warning_ignore("return_value_discarded")
_timer.timeout.connect(do_interrupt, CONNECT_DEFERRED)
_timer.set_one_shot(true)
_timer.set_wait_time(time)
_timer.set_autostart(false)
_timer.start()
func do_interrupt() -> void:
_interupted = true
# We need to dispose manually the function state here
GdObjects.dispose_function_state(_func_state)
if not is_expect_interupted():
var execution_context:= GdUnitThreadManager.get_current_context().get_execution_context()
if is_fuzzed():
execution_context.add_report(GdUnitReport.new()\
.create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.fuzzer_interuped(_current_iteration, "timedout")))
else:
execution_context.add_report(GdUnitReport.new()\
.create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.test_timeout(_attribute.timeout)))
completed.emit()
func _set_failure_handler() -> void:
if not GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received):
@warning_ignore("return_value_discarded")
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() -> void:
# 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 _attribute.is_skipped
func skip_info() -> String:
return _attribute.skip_reason
func id() -> GdUnitGUID:
return _test_case.guid
func test_name() -> String:
return _test_case.test_name
@warning_ignore("native_method_override")
func get_name() -> StringName:
return _test_case.test_name
func line_number() -> int:
return _test_case.line_number
func iterations() -> int:
return _attribute.fuzzer_iterations
func seed_value() -> int:
return _attribute.test_seed
func is_fuzzed() -> bool:
return not _attribute.fuzzers.is_empty()
func fuzzer_arguments() -> Array[GdFunctionArgument]:
return _attribute.fuzzers
func script_path() -> String:
return _test_case.source_file
func ResourcePath() -> String:
return _test_case.source_file
func generate_seed() -> void:
if _attribute.test_seed != -1:
seed(_attribute.test_seed)
func do_skip(skipped: bool, reason: String="") -> void:
_attribute.is_skipped = skipped
_attribute.skip_reason = reason
func set_function_descriptor(fd: GdFunctionDescriptor) -> void:
_parameter_set_resolver = GdUnitTestParameterSetResolver.new(fd)
func _to_string() -> String:
return "%s :%d (%dms)" % [get_name(), _test_case.line_number, _attribute.timeout]

View File

@@ -0,0 +1 @@
uid://cb2lkpvh0liiv

Binary file not shown.

View File

@@ -2,12 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://b8nasq23r33s3"
valid=false
uid="uid://csgvrbao53xmv"
path="res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/gdUnit4/src/core/assets/touch-button.png"
dest_files=["res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex"]
[params]

View File

@@ -0,0 +1,76 @@
class_name TestCaseAttribute
extends Resource
## Holds configuration and metadata for individual test cases.[br]
## [br]
## This class defines test behaviors and properties such as:[br]
## - Test timeouts[br]
## - Skip conditions[br]
## - Fuzzing parameters[br]
## - Random seed values[br]
## When set, no specific timeout value is configured and test will use the [code]test_timeout[/code][br]
## value from [GdUnitSettings].
const DEFAULT_TIMEOUT := -1
## The maximum time in milliseconds for test completion.[br]
## The test fails if execution exceeds this duration.[br]
## [br]
## When set to [constant DEFAULT_TIMEOUT], uses the value from [method GdUnitSettings.test_timeout].
var timeout: int = DEFAULT_TIMEOUT:
set(value):
timeout = value
get:
if timeout == DEFAULT_TIMEOUT:
# get the default timeout from the settings
timeout = GdUnitSettings.test_timeout()
return timeout
## The seed used for random number generation in the test.[br]
## Ensures reproducible results for randomized test scenarios.[br]
## A value of -1 indicates no specific seed is set.
var test_seed: int = -1
## Controls whether this test should be skipped during execution.[br]
## Useful for temporarily disabling tests without removing them.
var is_skipped := false
## Documents why the test is being skipped.[br]
## [br]
## Should explain the reason for skipping and ideally include:[br]
## - Why the test was disabled[br]
## - Under what conditions it should be re-enabled[br]
## - Any related issues or tickets
var skip_reason := "Unknown"
## Number of iterations to run when using fuzzers.[br]
## [br]
## Fuzzers generate random test data to help find edge cases.[br]
## Higher values provide better coverage but increase test duration.
var fuzzer_iterations: int = Fuzzer.ITERATION_DEFAULT_COUNT
## Array of fuzzer configurations for test parameters.[br]
## [br]
## Each [GdFunctionArgument] defines how random test data[br]
## should be generated for a particular parameter.
var fuzzers: Array[GdFunctionArgument] = []
# There is a bug in `duplicate` see https://github.com/godotengine/godot/issues/98644
# we need in addition to overwrite default values with the source values
@warning_ignore("native_method_override")
func clone() -> Resource:
var copy: TestCaseAttribute = TestCaseAttribute.new()
copy.timeout = timeout
copy.test_seed = test_seed
copy.is_skipped = is_skipped
copy.skip_reason = skip_reason
copy.fuzzer_iterations = fuzzer_iterations
copy.fuzzers = fuzzers.duplicate()
return copy

View File

@@ -0,0 +1 @@
uid://d2bres53mgxnw

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) -> void:
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 @@
uid://crmuuvbqy4shs

View File

@@ -0,0 +1,408 @@
class_name GdUnitCommandHandler
extends Object
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_TESTSUITE,
GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG : GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG
}
const CommandMapping := {
GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL: GdUnitCommandHandler.CMD_RUN_OVERALL,
GdUnitShortcut.ShortCut.RUN_TESTCASE: GdUnitCommandHandler.CMD_RUN_TESTCASE,
GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG: GdUnitCommandHandler.CMD_RUN_TESTCASE_DEBUG,
GdUnitShortcut.ShortCut.RUN_TESTSUITE: GdUnitCommandHandler.CMD_RUN_TESTSUITE,
GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG: GdUnitCommandHandler.CMD_RUN_TESTSUITE_DEBUG,
GdUnitShortcut.ShortCut.RERUN_TESTS: GdUnitCommandHandler.CMD_RERUN_TESTS,
GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG: GdUnitCommandHandler.CMD_RERUN_TESTS_DEBUG,
GdUnitShortcut.ShortCut.STOP_TEST_RUN: GdUnitCommandHandler.CMD_STOP_TEST_RUN,
GdUnitShortcut.ShortCut.CREATE_TEST: GdUnitCommandHandler.CMD_CREATE_TESTCASE,
}
# 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() -> GdUnitCommandHandler: return GdUnitCommandHandler.new())
@warning_ignore("return_value_discarded")
func _init() -> void:
assert_shortcut_mappings(SETTINGS_SHORTCUT_MAPPING)
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
@warning_ignore("return_value_discarded")
_runner_config.load_config()
init_shortcuts()
var is_running := func(_script :Script) -> bool: return _is_running
var is_not_running := func(_script :Script) -> bool: 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), GdUnitShortcut.ShortCut.RUN_TESTSUITE))
register_command(GdUnitCommand.new(CMD_RUN_TESTSUITE_DEBUG, is_not_running, cmd_run_test_suites.bind(true), GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG))
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))
# schedule discover tests if enabled and running inside the editor
if Engine.is_editor_hint() and GdUnitSettings.is_test_discover_enabled():
var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(5)
@warning_ignore("return_value_discarded")
timer.timeout.connect(cmd_discover_tests)
func _notification(what: int) -> void:
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() -> void:
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() -> bool:
return _running_debug_mode and _is_running and not EditorInterface.is_playing_scene()
func assert_shortcut_mappings(mappings: Dictionary) -> void:
for shortcut: int 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: int 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.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 := 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 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(scripts: Array[Script], debug: bool, rerun := false) -> void:
# Update test discovery
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
var tests_to_execute: Array[GdUnitTestCase] = []
for script in scripts:
GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void:
tests_to_execute.append(test_case)
GdUnitTestDiscoverSink.discover(test_case)
)
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute)
# create new runner runner_config for fresh run otherwise use saved one
if not rerun:
var result := _runner_config.clear()\
.add_test_cases(tests_to_execute)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run_test_case(script: Script, test_case: String, test_param_index: int, debug: bool, rerun := false) -> void:
# Update test discovery
var tests_to_execute: Array[GdUnitTestCase] = []
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
# We filter for a single test
if test.test_name == test_case:
# We only add selected parameterized test to the execution list
if test_param_index == -1:
tests_to_execute.append(test)
elif test.attribute_index == test_param_index:
tests_to_execute.append(test)
GdUnitTestDiscoverSink.discover(test)
)
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute)
# create new runner config for fresh run otherwise use saved one
if not rerun:
var result := _runner_config.clear()\
.add_test_cases(tests_to_execute)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run_tests(tests_to_execute: Array[GdUnitTestCase], debug: bool) -> void:
# Save tests to runner config before execute
var result := _runner_config.clear()\
.add_test_cases(tests_to_execute)\
.save_config()
if result.is_error():
push_error(result.error_message())
return
cmd_run(debug)
func cmd_run_overall(debug: bool) -> void:
var tests_to_execute := await GdUnitTestDiscoverer.run()
var result := _runner_config.clear()\
.add_test_cases(tests_to_execute)\
.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 server_port: int = Engine.get_meta("gdunit_server_port")
var result := _runner_config.set_server_port(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:
EditorInterface.stop_playing_scene()
elif _current_runner_process_id > 0:
if OS.is_process_running(_current_runner_process_id):
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) -> void:
if is_active_script_editor():
var cursor_line := active_base_editor().get_caret_line()
#run test case?
var regex := RegEx.new()
@warning_ignore("return_value_discarded")
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()
if func_name.begins_with("test_"):
cmd_run_test_case(active_script(), func_name, -1, debug)
return
# otherwise run the full test suite
var selected_test_suites: Array[Script] = [active_script()]
cmd_run_test_suites(selected_test_suites, debug)
func cmd_create_test() -> void:
if not is_active_script_editor():
return
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: Dictionary = result.value()
var script_path: String = info.get("path")
var script_line: int = info.get("line")
ScriptEditorControls.edit_script(script_path, script_line)
func cmd_discover_tests() -> void:
await GdUnitTestDiscoverer.run()
func run_debug_mode() -> void:
EditorInterface.play_custom_scene("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn")
_is_running = true
func run_release_mode() -> void:
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/runners/GdUnitTestRunner.tscn")
_current_runner_process_id = OS.create_process(OS.get_executable_path(), arguments, false);
_is_running = true
func is_active_script_editor() -> bool:
return EditorInterface.get_script_editor().get_current_editor() != null
func active_base_editor() -> TextEdit:
return EditorInterface.get_script_editor().get_current_editor().get_base_editor()
func active_script() -> Script:
return EditorInterface.get_script_editor().get_current_script()
################################################################################
# signals handles
################################################################################
func _on_event(event: GdUnitEvent) -> void:
if event.type() == GdUnitEvent.SESSION_CLOSE:
cmd_stop(_client_id)
func _on_stop_pressed() -> void:
cmd_stop(_client_id)
func _on_run_pressed(debug := false) -> void:
cmd_run(debug)
func _on_run_overall_pressed(_debug := false) -> void:
cmd_run_overall(true)
func _on_settings_changed(property: GdUnitProperty) -> void:
if SETTINGS_SHORTCUT_MAPPING.has(property.name()):
var shortcut :GdUnitShortcut.ShortCut = SETTINGS_SHORTCUT_MAPPING.get(property.name())
var value: PackedInt32Array = property.value()
var input_event := create_shortcut_input_even(value)
prints("Shortcut changed: '%s' to '%s'" % [GdUnitShortcut.ShortCut.keys()[shortcut], input_event.as_text()])
var action := get_shortcut_action(shortcut)
if action != null:
action.update_shortcut(input_event)
else:
register_shortcut(shortcut, input_event)
if property.name() == GdUnitSettings.TEST_DISCOVER_ENABLED:
var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(3)
@warning_ignore("return_value_discarded")
timer.timeout.connect(cmd_discover_tests)
################################################################################
# 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 @@
uid://dooc00u4rahqp

View File

@@ -0,0 +1,52 @@
class_name GdUnitShortcut
extends RefCounted
enum ShortCut {
NONE,
RUN_TESTS_OVERALL,
RUN_TESTCASE,
RUN_TESTCASE_DEBUG,
RUN_TESTSUITE,
RUN_TESTSUITE_DEBUG,
RERUN_TESTS,
RERUN_TESTS_DEBUG,
STOP_TEST_RUN,
CREATE_TEST,
}
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_TESTSUITE : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F5],
ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F6],
ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7],
ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8],
ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5],
ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, 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_TESTSUITE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5],
ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_CTRL,Key.KEY_ALT, Key.KEY_F6],
ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7],
ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8],
ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5],
ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, 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 @@
uid://bsg0clvy7wf0m

View File

@@ -0,0 +1,40 @@
class_name GdUnitShortcutAction
extends RefCounted
func _init(p_type :GdUnitShortcut.ShortCut, p_shortcut :Shortcut, p_command :String) -> void:
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 update_shortcut(input_event: InputEventKey) -> void:
shortcut.set_events([input_event])
func _to_string() -> String:
return "GdUnitShortcutAction: %s (%s) -> %s" % [GdUnitShortcut.ShortCut.keys()[type], shortcut.get_as_text(), command]

View File

@@ -0,0 +1 @@
uid://cmlh3hniafm5s

View File

@@ -0,0 +1,46 @@
## A class representing a globally unique identifier for GdUnit test elements.
## Uses random values to generate unique identifiers that can be used
## to track and reference test cases and suites across the test framework.
class_name GdUnitGUID
extends RefCounted
## The internal string representation of the GUID.
## Generated using Godot's ResourceUID system when no existing GUID is provided.
var _guid: String
## Creates a new GUID instance.
## If no GUID is provided, generates a new one using Godot's ResourceUID system.
func _init(from_guid: String = "") -> void:
if from_guid.is_empty():
_guid = _generate_guid()
else:
_guid = from_guid
## Compares this GUID with another for equality.
## Returns true if both GUIDs represent the same unique identifier.
func equals(other: GdUnitGUID) -> bool:
return other._guid == _guid
## Generates a custom GUID using random bytes.[br]
## The format uses 16 random bytes encoded to hex and formatted with hyphens.
static func _generate_guid() -> String:
# Pre-allocate array with exact size needed
var bytes := PackedByteArray()
bytes.resize(16)
# Fill with random bytes
for i in range(16):
bytes[i] = randi() % 256
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytes.hex_encode().insert(8, "-").insert(16, "-").insert(24, "-")
func _to_string() -> String:
return _guid

View File

@@ -0,0 +1 @@
uid://d4lobvde8tufj

View File

@@ -0,0 +1 @@
uid://i4kgxeu6rjiv

View File

@@ -0,0 +1 @@
uid://cojycdwxjbkf3

View File

@@ -0,0 +1,13 @@
## A static utility class that acts as a central sink for test case discovery events in GdUnit4.
## Instead of implementing custom sink classes, test discovery consumers should connect to
## the GdUnitSignals.gdunit_test_discovered signal to receive test case discoveries.
## This design allows for a more flexible and decoupled test discovery system.
class_name GdUnitTestDiscoverSink
extends RefCounted
## Emits a discovered test case through the GdUnitSignals system.[br]
## Sends the test case to all listeners connected to the gdunit_test_discovered signal.[br]
## [member test_case] The discovered test case to be broadcast to all connected listeners.
static func discover(test_case: GdUnitTestCase) -> void:
GdUnitSignals.instance().gdunit_test_discover_added.emit(test_case)

View File

@@ -0,0 +1 @@
uid://ct0kk6824vhxf

View File

@@ -0,0 +1,171 @@
class_name GdUnitTestDiscoverer
extends RefCounted
static func run() -> Array[GdUnitTestCase]:
console_log("Running test discovery ..")
await (Engine.get_main_loop() as SceneTree).process_frame
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
# We run the test discovery in an extra thread so that the main thread is not blocked
var t:= Thread.new()
@warning_ignore("return_value_discarded")
t.start(func () -> Array[GdUnitTestCase]:
# Loading previous test session
var runner_config := GdUnitRunnerConfig.new()
runner_config.load_config()
var recovered_tests := runner_config.test_cases()
var test_suite_directories := scan_all_test_directories(GdUnitSettings.test_root_folder())
var scanner := GdUnitTestSuiteScanner.new()
var collected_tests: Array[GdUnitTestCase] = []
var collected_test_suites: Array[Script] = []
# collect test suites
for test_suite_dir in test_suite_directories:
collected_test_suites.append_array(scanner.scan_directory(test_suite_dir))
# Do sync the main thread before emit the discovered test suites to the inspector
await (Engine.get_main_loop() as SceneTree).process_frame
for test_suites_script in collected_test_suites:
discover_tests(test_suites_script, func(test_case: GdUnitTestCase) -> void:
# Sync test uid from last test session
recover_test_guid(test_case, recovered_tests)
collected_tests.append(test_case)
GdUnitTestDiscoverSink.discover(test_case)
)
console_log_discover_results(collected_tests)
if !recovered_tests.is_empty():
console_log("Recovered last test session successfully, %d tests restored." % recovered_tests.size(), true)
return collected_tests
)
# wait unblocked to the tread is finished
while t.is_alive():
await (Engine.get_main_loop() as SceneTree).process_frame
# needs finally to wait for finish
var test_to_execute: Array[GdUnitTestCase] = await t.wait_to_finish()
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
return test_to_execute
## Restores the last test run session by loading the test run config file and rediscover the tests
static func restore_last_session() -> void:
if GdUnitSettings.is_test_discover_enabled():
return
var runner_config := GdUnitRunnerConfig.new()
var result := runner_config.load_config()
# Report possible config loading errors
if result.is_error():
console_log("Recovery of the last test session failed: %s" % result.error_message(), true)
# If no config file found, skip test recovery
if result.is_warn():
return
# If no tests recorded, skip test recovery
var test_cases := runner_config.test_cases()
if test_cases.size() == 0:
return
# We run the test session restoring in an extra thread so that the main thread is not blocked
var t:= Thread.new()
t.start(func () -> void:
# Do sync the main thread before emit the discovered test suites to the inspector
await (Engine.get_main_loop() as SceneTree).process_frame
console_log("Recovering last test session ..", true)
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
for test_case in test_cases:
GdUnitTestDiscoverSink.discover(test_case)
GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
console_log("Recovered last test session successfully, %d tests restored." % test_cases.size(), true)
)
t.wait_to_finish()
static func recover_test_guid(current: GdUnitTestCase, recovered_tests: Array[GdUnitTestCase]) -> void:
for recovered_test in recovered_tests:
if recovered_test.fully_qualified_name == current.fully_qualified_name:
current.guid = recovered_test.guid
static func console_log_discover_results(tests: Array[GdUnitTestCase]) -> void:
var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
return test.source_file
)
for suite_tests: Array in grouped_by_suites.values():
var test_case: GdUnitTestCase = suite_tests[0]
console_log("Discover: TestSuite %s with %d tests found" % [test_case.source_file, suite_tests.size()])
console_log("Discover tests done, %d TestSuites and total %d Tests found. " % [grouped_by_suites.size(), tests.size()])
console_log("")
static func console_log(message: String, on_console := false) -> void:
prints(message)
if on_console:
GdUnitSignals.instance().gdunit_message.emit(message)
static func filter_tests(method: Dictionary) -> bool:
var method_name: String = method["name"]
return method_name.begins_with("test_")
static func default_discover_sink(test_case: GdUnitTestCase) -> void:
GdUnitTestDiscoverSink.discover(test_case)
static func discover_tests(source_script: Script, discover_sink := default_discover_sink) -> void:
if source_script is GDScript:
var test_names := source_script.get_script_method_list()\
.filter(filter_tests)\
.map(func(method: Dictionary) -> String: return method["name"])
# no tests discovered?
if test_names.is_empty():
return
var parser := GdScriptParser.new()
var fds := parser.get_function_descriptors(source_script as GDScript, test_names)
for fd in fds:
var resolver := GdFunctionParameterSetResolver.new(fd)
for test_case in resolver.resolve_test_cases(source_script as GDScript):
discover_sink.call(test_case)
elif source_script.get_class() == "CSharpScript":
if not GdUnit4CSharpApiLoader.is_api_loaded():
return
for test_case in GdUnit4CSharpApiLoader.discover_tests(source_script):
discover_sink.call(test_case)
static func scan_all_test_directories(root: String) -> PackedStringArray:
var base_directory := "res://"
# If the test root folder is configured as blank, "/", or "res://", use the root folder as described in the settings panel
if root.is_empty() or root == "/" or root == base_directory:
return [base_directory]
return scan_test_directories(base_directory, root, [])
static func scan_test_directories(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 FileAccess.file_exists(current_directory + "/.gdignore"):
continue
if GdUnitTestSuiteScanner.exclude_scan_directories.has(current_directory):
continue
if match_test_directory(directory, test_directory):
@warning_ignore("return_value_discarded")
test_suite_paths.append(current_directory)
else:
@warning_ignore("return_value_discarded")
scan_test_directories(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://"

View File

@@ -0,0 +1 @@
uid://uakc3vyaaagr

View File

@@ -0,0 +1,206 @@
class_name GdUnitEvent
extends Resource
const WARNINGS = "warnings"
const FAILED = "failed"
const FLAKY = "flaky"
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"
const RETRY_COUNT = "retry_count"
enum {
INIT,
STOP,
TESTSUITE_BEFORE,
TESTSUITE_AFTER,
TESTCASE_BEFORE,
TESTCASE_AFTER,
DISCOVER_START,
DISCOVER_END,
SESSION_START,
SESSION_CLOSE
}
var _event_type: int
var _guid: GdUnitGUID
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:
_guid = GdUnitGUID.new()
_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:
_guid = GdUnitGUID.new()
_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_guid: GdUnitGUID) -> GdUnitEvent:
_event_type = TESTCASE_BEFORE
_guid = p_guid
return self
func test_after(p_guid: GdUnitGUID, p_statistics: Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent:
_event_type = TESTCASE_AFTER
_guid = p_guid
_statistics = p_statistics
_reports = p_reports
return self
func type() -> int:
return _event_type
func guid() -> GdUnitGUID:
return _guid
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 retry_count() -> int:
return _statistics.get(RETRY_COUNT, 0)
func resource_path() -> String:
return _resource_path
func is_success() -> bool:
return not is_failed() and not is_error()
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_flaky() -> bool:
return _statistics.get(FLAKY, false)
func is_skipped() -> bool:
return _statistics.get(SKIPPED, false)
func reports() -> Array[GdUnitReport]:
return _reports
func _to_string() -> String:
return "Event: %s id:%s %s:%s, %s, %s" % [_event_type, _guid, _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
}
if _guid != null:
serialized["guid"] = _guid._guid
serialized["reports"] = _serialize_TestReports()
return serialized
func deserialize(serialized: Dictionary) -> GdUnitEvent:
_event_type = serialized.get("type", null)
_guid = GdUnitGUID.new(str(serialized.get("guid", "")))
_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_to_deserializ :Array[Dictionary] = []
@warning_ignore("unsafe_cast")
reports_to_deserializ.append_array(serialized.get("reports") as Array)
_reports = _deserialize_reports(reports_to_deserializ)
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 @@
uid://c4wkq83n4a4bk

View File

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

View File

@@ -0,0 +1 @@
uid://c8t36rmkcsvqm

View File

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

View File

@@ -0,0 +1 @@
uid://cg768i3qgef2x

View File

@@ -0,0 +1,19 @@
class_name GdUnitEventTestDiscoverEnd
extends GdUnitEvent
var _total_testsuites: int
func _init(testsuite_count: int, test_count: int) -> void:
_event_type = DISCOVER_END
_total_testsuites = testsuite_count
_total_count = test_count
func total_test_suites() -> int:
return _total_testsuites
func total_tests() -> int:
return _total_count

View File

@@ -0,0 +1 @@
uid://bt4blgp4lw3p0

View File

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

View File

@@ -0,0 +1 @@
uid://npuh47e34ud2

View File

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

View File

@@ -0,0 +1 @@
uid://eqiw85rg4fgn

View File

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

View File

@@ -0,0 +1 @@
uid://hpagtimkbhev

View File

@@ -0,0 +1,269 @@
## The execution context
## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor
class_name GdUnitExecutionContext
enum GC_ORPHANS_CHECK {
NONE,
SUITE_HOOK_AFTER,
TEST_HOOK_AFTER,
TEST_CASE
}
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 _test_case_parameter_set: Array
var _name: String
var _test_execution_iteration: int = 0
var _flaky_test_check := GdUnitSettings.is_test_flaky_check_enabled()
var _flaky_test_retries := GdUnitSettings.get_flaky_max_retries()
var _orphans := -1
var error_monitor: GodotGdErrorMonitor = null:
get:
if _parent_context != null:
return _parent_context.error_monitor
if error_monitor == null:
error_monitor = GodotGdErrorMonitor.new()
return error_monitor
var test_suite: GdUnitTestSuite = null:
get:
if _parent_context != null:
return _parent_context.test_suite
return test_suite
var test_case: _TestCase = null:
get:
if test_case == null and _parent_context != null:
return _parent_context.test_case
return test_case
func _init(name: StringName, 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()
_report_collector = GdUnitTestReportCollector.new()
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
dispose_sub_contexts()
func dispose_sub_contexts() -> void:
for context in _sub_context:
context.dispose()
_sub_context.clear()
static func of(pe: GdUnitExecutionContext) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(pe._test_case_name, pe)
context._test_case_name = pe._test_case_name
context._test_execution_iteration = pe._test_execution_iteration
return context
static func of_test_suite(p_test_suite: GdUnitTestSuite) -> GdUnitExecutionContext:
assert(p_test_suite, "test_suite is null")
var context := GdUnitExecutionContext.new(p_test_suite.get_name())
context.test_suite = p_test_suite
return context
static func of_test_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext:
assert(p_test_case, "test_case is null")
var context := GdUnitExecutionContext.new(p_test_case.get_name(), pe)
context.test_case = p_test_case
return context
static func of_parameterized_test(pe: GdUnitExecutionContext, test_case_name: String, test_case_parameter_set: Array) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(test_case_name, pe)
context._test_case_name = test_case_name
context._test_case_parameter_set = test_case_parameter_set
return context
func get_test_suite_path() -> String:
return test_suite.get_script().resource_path
func get_test_suite_name() -> StringName:
return test_suite.get_name()
func get_test_case_name() -> StringName:
if _test_case_name.is_empty():
return test_case._test_case.display_name
return _test_case_name
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.push_back(error_report)
func orphan_monitor_start() -> void:
_orphan_monitor.start()
func orphan_monitor_stop() -> void:
_orphan_monitor.stop()
func add_report(report: GdUnitReport) -> GdUnitReport:
_report_collector.push_back(report)
return report
func reports() -> Array[GdUnitReport]:
return _report_collector.reports()
func collect_reports(recursive: bool) -> Array[GdUnitReport]:
if not recursive:
return reports()
# we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended`
# we strictly need to copy the reports before adding sub context reports to avoid manipulation of the current context
var current_reports := reports().duplicate()
for sub_context in _sub_context:
current_reports.append_array(sub_context.collect_reports(true))
return current_reports
func calculate_statistics(reports_: Array[GdUnitReport]) -> Dictionary:
var failed_count := GdUnitTestReportCollector.count_failures(reports_)
var error_count := GdUnitTestReportCollector.count_errors(reports_)
var warn_count := GdUnitTestReportCollector.count_warnings(reports_)
var skip_count := GdUnitTestReportCollector.count_skipped(reports_)
var is_failed := !is_success()
var orphan_count := _count_orphans()
var elapsed_time := _timer.elapsed_since_ms()
var retries := 1 if _parent_context == null else _sub_context.size()
# Mark as flaky if it is successful, but errors were counted
var is_flaky := retries > 1 and not is_failed
# In the case of a flakiness test, we do not report an error counter, as an unreliable test is considered successful
# after a certain number of repetitions.
if is_flaky:
failed_count = 0
return {
GdUnitEvent.RETRY_COUNT: retries,
GdUnitEvent.ELAPSED_TIME: elapsed_time,
GdUnitEvent.FAILED: is_failed,
GdUnitEvent.ERRORS: error_count > 0,
GdUnitEvent.WARNINGS: warn_count > 0,
GdUnitEvent.FLAKY: is_flaky,
GdUnitEvent.SKIPPED: skip_count > 0,
GdUnitEvent.FAILED_COUNT: failed_count,
GdUnitEvent.ERROR_COUNT: error_count,
GdUnitEvent.SKIPPED_COUNT: skip_count,
GdUnitEvent.ORPHAN_NODES: orphan_count,
}
func is_success() -> bool:
if _sub_context.is_empty():
return not _report_collector.has_failures()
# we on test suite level?
if _parent_context == null:
return not _report_collector.has_failures()
return _sub_context[-1].is_success() and not _report_collector.has_failures()
func is_skipped() -> bool:
return (
_sub_context.any(func(c :GdUnitExecutionContext) -> bool:
return c.is_skipped())
or test_case.is_skipped() if test_case != null else false
)
func is_interupted() -> bool:
return false if test_case == null else test_case.is_interupted()
func _count_orphans() -> int:
if _orphans != -1:
return _orphans
var orphans := 0
for c in _sub_context:
if _orphan_monitor.orphan_nodes() != c._orphan_monitor.orphan_nodes():
orphans += c._count_orphans()
_orphans = _orphan_monitor.orphan_nodes()
if _orphan_monitor.orphan_nodes() != orphans:
_orphans -= orphans
return _orphans
func sum(accum: int, number: int) -> int:
return accum + number
func retry_execution() -> bool:
var retry := _test_execution_iteration < 1 if not _flaky_test_check else _test_execution_iteration < _flaky_test_retries
if retry:
_test_execution_iteration += 1
return retry
func register_auto_free(obj: Variant) -> Variant:
return _memory_observer.register_auto_free(obj)
## Runs the gdunit garbage collector to free registered object and handle orphan node reporting
func gc(gc_orphan_check: GC_ORPHANS_CHECK = GC_ORPHANS_CHECK.NONE) -> void:
# unreference last used assert form the test to prevent memory leaks
GdUnitThreadManager.get_current_context().clear_assert()
await _memory_observer.gc()
orphan_monitor_stop()
var orphans := _count_orphans()
match(gc_orphan_check):
GC_ORPHANS_CHECK.SUITE_HOOK_AFTER:
if orphans > 0:
reports().push_front(GdUnitReport.new() \
.create(GdUnitReport.WARN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphans)))
GC_ORPHANS_CHECK.TEST_HOOK_AFTER:
if orphans > 0:
reports().push_front(GdUnitReport.new()\
.create(GdUnitReport.WARN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans)))
GC_ORPHANS_CHECK.TEST_CASE:
if orphans > 0:
reports().push_front(GdUnitReport.new()\
.create(GdUnitReport.WARN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans)))

View File

@@ -0,0 +1 @@
uid://dm5otinunwsc1

View File

@@ -0,0 +1,135 @@
## 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) -> Variant:
if not is_instance_valid(obj):
return obj
# do not register on GDScriptNativeClass
@warning_ignore("unsafe_cast")
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()
@warning_ignore("unsafe_method_access")
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:
@warning_ignore("unsafe_method_access")
prints(name, obj, obj.get_class(), obj.get_name())
static func guard_instance(obj :Object) -> void:
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)
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() as SceneTree).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 as RefCounted).unreference()
await (Engine.get_main_loop() as SceneTree).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 :Object = 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: Array = Engine.get_meta(TAG_AUTO_FREE, [])
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() as SceneTree).process_frame
if _is_stdout_verbose:
print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size())
var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
while not _store.is_empty():
var value :Variant = _store.pop_front()
tagged_objects.erase(value)
await GdUnitTools.free_instance(value, _is_stdout_verbose)
assert(_store.is_empty(), "The memory observer has still entries in the store!")
## Checks whether the specified object is registered for automatic release
static func is_marked_auto_free(obj: Variant) -> bool:
var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
return tagged_objects.has(obj)

View File

@@ -0,0 +1 @@
uid://ibpnqu61f7yw

View File

@@ -0,0 +1,62 @@
# Collects all reports seperated as warnings, failures and errors
class_name GdUnitTestReportCollector
extends RefCounted
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()
static func count_failures(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_failure).size()
static func count_errors(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_error).size()
static func count_warnings(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_warning).size()
static func count_skipped(reports_: Array[GdUnitReport]) -> 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
func push_back(report :GdUnitReport) -> void:
_reports.push_back(report)

View File

@@ -0,0 +1 @@
uid://cl13ejhh26vv7

View File

@@ -0,0 +1,48 @@
## 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 := GdUnitTestSuiteExecutionStage.new()
var _debug_mode : bool
func _init(debug_mode :bool = false) -> void:
_executeStage.set_debug_mode(debug_mode)
_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() as SceneTree).root.call_deferred("add_child", test_suite)
await (Engine.get_main_loop() as SceneTree).process_frame
await _executeStage.execute(GdUnitExecutionContext.of_test_suite(test_suite))
func run_and_wait(tests: Array[GdUnitTestCase]) -> void:
if !_debug_mode:
GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new())
# first we group all tests by resource path
var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
return test.suite_resource_path
)
var scanner := GdUnitTestSuiteScanner.new()
for suite_path: String in grouped_by_suites.keys():
@warning_ignore("unsafe_call_argument")
var suite_tests: Array[GdUnitTestCase] = Array(grouped_by_suites[suite_path], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(suite_path)
if script.get_class() == "GDScript":
var test_suite := scanner.load_suite(script as GDScript, suite_tests)
await execute(test_suite)
else:
await GdUnit4CSharpApiLoader.execute(suite_tests)
if !_debug_mode:
GdUnitSignals.instance().gdunit_event.emit(GdUnitStop.new())
func fail_fast(enabled :bool) -> void:
_executeStage.fail_fast(enabled)

View File

@@ -0,0 +1 @@
uid://hl8otc6pepsh

View File

@@ -0,0 +1,22 @@
## The test case shutdown hook implementation.[br]
## It executes the 'test_after()' block from the test-suite.
class_name GdUnitTestCaseAfterStage
extends IGdUnitExecutionStage
var _call_stage: bool
func _init(call_stage := true) -> void:
_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()
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_HOOK_AFTER)
await context.error_monitor_stop()

View File

@@ -0,0 +1 @@
uid://ddknkun7aw51d

View File

@@ -0,0 +1,19 @@
## The test case startup hook implementation.[br]
## It executes the 'test_before()' block from the test-suite.
class_name GdUnitTestCaseBeforeStage
extends IGdUnitExecutionStage
var _call_stage :bool
func _init(call_stage := true) -> void:
_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.before_test()
context.error_monitor_start()

View File

@@ -0,0 +1 @@
uid://c8gq3sb8q6xih

View File

@@ -0,0 +1,37 @@
## 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()
## 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
context.error_monitor_start()
if test_case.is_fuzzed():
await _stage_fuzzer_test.execute(context)
else:
await _stage_single_test.execute(context)
await context.gc()
await context.error_monitor_stop()
# finally free the test instance
if is_instance_valid(context.test_case):
context.test_case.dispose()
func set_debug_mode(debug_mode :bool = false) -> void:
super.set_debug_mode(debug_mode)
_stage_single_test.set_debug_mode(debug_mode)
_stage_fuzzer_test.set_debug_mode(debug_mode)

View File

@@ -0,0 +1,29 @@
## 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()
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.SUITE_HOOK_AFTER)
var reports := context.collect_reports(false)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new()\
.suite_after(context.get_test_suite_path(),\
test_suite.get_name(),
statistics,
reports))
GdUnitFileAccess.clear_tmp()
# Guard that checks if all doubled (spy/mock) objects are released
await GdUnitClassDoubler.check_leaked_instances()
# we hide the scene/main window after runner is finished
if not Engine.is_embedded_in_editor():
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)

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(context.get_test_suite_path(), test_suite.get_name(), test_suite.get_child_count()))
@warning_ignore("redundant_await")
await test_suite.before()

View File

@@ -0,0 +1,147 @@
## 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:
@warning_ignore("return_value_discarded")
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.test_name())
await _stage_test.execute(GdUnitExecutionContext.of_test_case(context, test_case))
# stop on first error or if fail fast is enabled
if _fail_fast and not context.is_success():
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() as SceneTree).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() as SceneTree).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)
@warning_ignore("return_value_discarded")
GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter)
# finally free current test suite instance
test_suite.free()
await (Engine.get_main_loop() as SceneTree).process_frame
return _test_suite
func dispose_timers(test_suite :GdUnitTestSuite) -> void:
GdUnitTools.release_timers()
for child in test_suite.get_children():
if child is Timer:
(child as Timer).stop()
test_suite.remove_child(child)
child.free()
func copy_properties(source :Object, target :Object) -> void:
if not source is _TestCase and not source is GdUnitTestSuite:
return
for property in source.get_property_list():
var property_name :String = 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(context.get_test_suite_path(), test_suite.get_name(), skip_count))
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
var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case)
fire_event(GdUnitEvent.new().test_before(test_case.id()))
# use skip count 0 because we counted it over the complete test suite
fire_test_skipped(test_case_context, 0)
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(context.get_test_suite_path(), test_suite.get_name(), statistics, [report]))
await (Engine.get_main_loop() as SceneTree).process_frame
func fire_test_skipped(context: GdUnitExecutionContext, skip_count := 1) -> void:
var test_case := context.test_case
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: skip_count,
}
var report := GdUnitReport.new() \
.create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped("Skipped from the entire test suite"))
fire_event(GdUnitEvent.new().test_after(test_case.id(), statistics, [report]))
func set_debug_mode(debug_mode :bool = false) -> void:
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:
GdUnitThreadManager.get_current_context().set_execution_context(context)
@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 @@
uid://blqjb8vicbune

View File

@@ -0,0 +1,52 @@
## 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:
fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
while context.retry_execution():
var test_context := GdUnitExecutionContext.of(context)
await _stage_before.execute(test_context)
if not context.test_case.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(test_context))
await _stage_after.execute(test_context)
if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
break
context.gc()
if context.is_skipped():
fire_test_skipped(context)
else:
var reports: = context.collect_reports(true)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new().test_after(context.test_case.id(), statistics, reports))
func set_debug_mode(debug_mode :bool = false) -> void:
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 fire_test_skipped(context: GdUnitExecutionContext) -> void:
var test_case := context.test_case
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_case.id(), statistics, [report]))

View File

@@ -0,0 +1,55 @@
## 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:
@warning_ignore("return_value_discarded")
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(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
# 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():
@warning_ignore("unsafe_cast")
var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script() as GDScript, fuzzer_arg.plain_value() as String)
fuzzer._iteration_index = 0
fuzzer._iteration_limit = test_case.iterations()
fuzzers.append(fuzzer)
return fuzzers

View File

@@ -0,0 +1,53 @@
## 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:
fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
while context.retry_execution():
var test_context := GdUnitExecutionContext.of(context)
await _stage_before.execute(test_context)
if not test_context.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(test_context))
await _stage_after.execute(test_context)
if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
break
context.gc()
if context.is_skipped():
fire_test_skipped(context)
else:
var reports: = context.collect_reports(true)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new().test_after(context.test_case.id(), statistics, reports))
func set_debug_mode(debug_mode :bool = false) -> void:
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 fire_test_skipped(context: GdUnitExecutionContext) -> void:
var test_case := context.test_case
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_case.id(), statistics, [report]))

Some files were not shown because too many files have changed in this diff Show More