diff options
| author | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
|---|---|---|
| committer | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
| commit | a89e744ed0e8c759caf041a580112365bbfe4303 (patch) | |
| tree | dd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src/core | |
| parent | 6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff) | |
Diffstat (limited to 'addons/gdUnit4/src/core')
187 files changed, 11472 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd b/addons/gdUnit4/src/core/GdArrayTools.gd new file mode 100644 index 0000000..74f0e17 --- /dev/null +++ b/addons/gdUnit4/src/core/GdArrayTools.gd @@ -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) diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd.uid b/addons/gdUnit4/src/core/GdArrayTools.gd.uid new file mode 100644 index 0000000..ca8fecb --- /dev/null +++ b/addons/gdUnit4/src/core/GdArrayTools.gd.uid @@ -0,0 +1 @@ +uid://ojsvg5sbjqmr diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd b/addons/gdUnit4/src/core/GdDiffTool.gd new file mode 100644 index 0000000..5131df7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdDiffTool.gd @@ -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 diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd.uid b/addons/gdUnit4/src/core/GdDiffTool.gd.uid new file mode 100644 index 0000000..7b60098 --- /dev/null +++ b/addons/gdUnit4/src/core/GdDiffTool.gd.uid @@ -0,0 +1 @@ +uid://ccj7h0qml5bng diff --git a/addons/gdUnit4/src/core/GdObjects.gd b/addons/gdUnit4/src/core/GdObjects.gd new file mode 100644 index 0000000..279e518 --- /dev/null +++ b/addons/gdUnit4/src/core/GdObjects.gd @@ -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 diff --git a/addons/gdUnit4/src/core/GdObjects.gd.uid b/addons/gdUnit4/src/core/GdObjects.gd.uid new file mode 100644 index 0000000..dc1034b --- /dev/null +++ b/addons/gdUnit4/src/core/GdObjects.gd.uid @@ -0,0 +1 @@ +uid://cfk1e01rfphf0 diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd b/addons/gdUnit4/src/core/GdUnit4Version.gd new file mode 100644 index 0000000..3e6a334 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnit4Version.gd @@ -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] diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd.uid b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid new file mode 100644 index 0000000..22beb73 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid @@ -0,0 +1 @@ +uid://dm2llfcfmgmox diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd b/addons/gdUnit4/src/core/GdUnitFileAccess.gd new file mode 100644 index 0000000..f4ba003 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd @@ -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() + + +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) diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid new file mode 100644 index 0000000..91c595c --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid @@ -0,0 +1 @@ +uid://cqbvlw7jnsrm8 diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd b/addons/gdUnit4/src/core/GdUnitProperty.gd new file mode 100644 index 0000000..3d050b1 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitProperty.gd @@ -0,0 +1,81 @@ +class_name GdUnitProperty +extends RefCounted + + +var _name :String +var _help :String +var _type :int +var _value :Variant +var _value_set :PackedStringArray +var _default :Variant + + +func _init(p_name :String, p_type :int, p_value :Variant, p_default_value :Variant, p_help :="", p_value_set := PackedStringArray()) -> void: + _name = p_name + _type = p_type + _value = p_value + _value_set = p_value_set + _default = p_default_value + _help = p_help + + +func name() -> String: + return _name + + +func type() -> int: + return _type + + +func value() -> Variant: + return _value + + +func int_value() -> int: + return _value + +func value_as_string() -> String: + return _value + + +func value_set() -> PackedStringArray: + return _value_set + + +func is_selectable_value() -> bool: + return not _value_set.is_empty() + + +func set_value(p_value: Variant) -> void: + match _type: + TYPE_STRING: + _value = str(p_value) + TYPE_BOOL: + _value = type_convert(p_value, TYPE_BOOL) + TYPE_INT: + _value = type_convert(p_value, TYPE_INT) + TYPE_FLOAT: + _value = type_convert(p_value, TYPE_FLOAT) + TYPE_DICTIONARY: + _value = type_convert(p_value, TYPE_DICTIONARY) + _: + _value = p_value + + +func default() -> Variant: + return _default + + +func category() -> String: + var elements := _name.split("/") + if elements.size() > 3: + return elements[2] + return "" + + +func help() -> String: + return _help + + +func _to_string() -> String: + return "%-64s %-10s %-10s (%s) help:%s set:%s" % [name(), type(), value(), default(), help(), _value_set] diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd.uid b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid new file mode 100644 index 0000000..e1220b6 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid @@ -0,0 +1 @@ +uid://bcfqsjp4j23io diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd b/addons/gdUnit4/src/core/GdUnitResult.gd new file mode 100644 index 0000000..42392a5 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitResult.gd @@ -0,0 +1,109 @@ +class_name GdUnitResult +extends RefCounted + +enum { + SUCCESS, + WARN, + ERROR, + EMPTY +} + +var _state: int +var _warn_message := "" +var _error_message := "" +var _value :Variant = null + + +static func empty() -> GdUnitResult: + var result := GdUnitResult.new() + result._state = EMPTY + return result + + +static func success(p_value: Variant = "") -> GdUnitResult: + assert(p_value != null, "The value must not be NULL") + var result := GdUnitResult.new() + result._value = p_value + result._state = SUCCESS + return result + + +static func warn(p_warn_message: String, p_value: Variant = null) -> GdUnitResult: + assert(not p_warn_message.is_empty()) #,"The message must not be empty") + var result := GdUnitResult.new() + result._value = p_value + result._warn_message = p_warn_message + result._state = WARN + return result + + +static func error(p_error_message: String) -> GdUnitResult: + assert(not p_error_message.is_empty(), "The message must not be empty") + var result := GdUnitResult.new() + result._value = null + result._error_message = p_error_message + result._state = ERROR + return result + + +func is_success() -> bool: + return _state == SUCCESS + + +func is_warn() -> bool: + return _state == WARN + + +func is_error() -> bool: + return _state == ERROR + + +func is_empty() -> bool: + return _state == EMPTY + + +func value() -> Variant: + return _value + + +func value_as_string() -> String: + return _value + + +func or_else(p_value: Variant) -> Variant: + if not is_success(): + return p_value + return value() + + +func error_message() -> String: + return _error_message + + +func warn_message() -> String: + return _warn_message + + +func _to_string() -> String: + return str(GdUnitResult.serialize(self)) + + +static func serialize(result: GdUnitResult) -> Dictionary: + if result == null: + push_error("Can't serialize a Null object from type GdUnitResult") + return { + "state" : result._state, + "value" : var_to_str(result._value), + "warn_msg" : result._warn_message, + "err_msg" : result._error_message + } + + +static func deserialize(config: Dictionary) -> GdUnitResult: + var result := GdUnitResult.new() + var cfg_value: String = config.get("value", "") + result._value = str_to_var(cfg_value) + result._warn_message = config.get("warn_msg", null) + result._error_message = config.get("err_msg", null) + result._state = config.get("state") + return result diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd.uid b/addons/gdUnit4/src/core/GdUnitResult.gd.uid new file mode 100644 index 0000000..52ea4fb --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitResult.gd.uid @@ -0,0 +1 @@ +uid://v42xavdbquis diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd new file mode 100644 index 0000000..0d1a42d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd @@ -0,0 +1,139 @@ +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, + + # Exit on first failure + EXIT_FAIL_FAST : false + } + + +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 do_fail_fast(fail_fast: bool) -> GdUnitRunnerConfig: + _config[EXIT_FAIL_FAST] = fail_fast + return self + + +func is_fail_fast() -> bool: + return _config.get(EXIT_FAIL_FAST, false) + + +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, + EXIT_FAIL_FAST : is_fail_fast(), + SERVER_PORT : 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) diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid new file mode 100644 index 0000000..f35bac7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid @@ -0,0 +1 @@ +uid://c4624ta2v365l diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd new file mode 100644 index 0000000..5c85252 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd @@ -0,0 +1,642 @@ +# 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) + Engine.set_meta("GdUnitSceneRunner", self) + # 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: + _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 + Engine.remove_meta("GdUnitSceneRunner") + + +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: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + 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: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + __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.set_alt_pressed(key_code == KEY_ALT) + event.set_shift_pressed(shift_pressed) + event.set_ctrl_pressed(ctrl_pressed) + event.get_modifiers_mask() + _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: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + __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.set_alt_pressed(key_code == KEY_ALT) + event.set_shift_pressed(shift_pressed) + event.set_ctrl_pressed(ctrl_pressed) + _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: + if scene() == null: + return "unknown" + 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 + if _last_input_event is InputEventKey and event is InputEventWithModifiers: + var last_input_event := _last_input_event as InputEventKey + var _event := event as InputEventWithModifiers + _event.shift_pressed = _event.shift_pressed or last_input_event.keycode == KEY_SHIFT + _event.alt_pressed = _event.alt_pressed or last_input_event.keycode == KEY_ALT + _event.ctrl_pressed = _event.ctrl_pressed or last_input_event.keycode == KEY_CTRL + _event.meta_pressed = _event.meta_pressed or last_input_event.keycode == KEY_META + + +# 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 + + +func _push_warning_deprecated_arguments(shift_pressed: bool, ctrl_pressed: bool) -> void: + if shift_pressed: + push_warning("Deprecated! Don't use 'shift_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.") + if ctrl_pressed: + push_warning("Deprecated! Don't use 'ctrl_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.") diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid new file mode 100644 index 0000000..a758b3b --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid @@ -0,0 +1 @@ +uid://qogbhj2t2ngs diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd b/addons/gdUnit4/src/core/GdUnitSettings.gd new file mode 100644 index 0000000..c4eaa96 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSettings.gd @@ -0,0 +1,443 @@ +@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" +const TEST_RERUN_UNTIL_FAILURE_RETRIES = GROUP_TEST + "/rerun_until_failure_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 SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_until_failure" + +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") + create_property_if_need(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10, "The number of reruns until the test fails.") + # 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_RERUN_TEST_UNTIL_FAILURE, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE), "Rerun tests until failure occurs") + 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.RUN_TESTSUITE), "Run all test suites in the selected folder or file") + create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG), "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 get_rerun_max_retries() -> int: + return get_setting(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10) + + +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") diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd.uid b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid new file mode 100644 index 0000000..3fcb866 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid @@ -0,0 +1 @@ +uid://dvy5w5kr2i6e6 diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd new file mode 100644 index 0000000..528e133 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd @@ -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) diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid new file mode 100644 index 0000000..366e962 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid @@ -0,0 +1 @@ +uid://ch6tenrmpno8w diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd new file mode 100644 index 0000000..0cb6ad7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd @@ -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("}") diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid new file mode 100644 index 0000000..628debc --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid @@ -0,0 +1 @@ +uid://dgwkonrctse1 diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd b/addons/gdUnit4/src/core/GdUnitSignals.gd new file mode 100644 index 0000000..f0a900d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignals.gd @@ -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 the user stops (terminates) the current test session +@warning_ignore("unused_signal") +signal gdunit_test_session_terminate() + + +## 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) diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd.uid b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid new file mode 100644 index 0000000..d4ca8db --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid @@ -0,0 +1 @@ +uid://071nom6h2642 diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd b/addons/gdUnit4/src/core/GdUnitSingleton.gd new file mode 100644 index 0000000..b8e08cc --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd @@ -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("----------------------------------------------------------------") diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid new file mode 100644 index 0000000..d54a18d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid @@ -0,0 +1 @@ +uid://b5fwmspo4wq8k diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd new file mode 100644 index 0000000..33b80e2 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd @@ -0,0 +1,97 @@ +class_name GdUnitTestResourceLoader +extends RefCounted + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +enum { + GD_SUITE, + CS_SUITE +} + + +static func load_test_suite(resource_path: String, script_type := GD_SUITE) -> Node: + match script_type: + GD_SUITE: + return load_test_suite_gd(resource_path) + CS_SUITE: + return load_test_suite_cs(resource_path) + assert("type '%s' is not implemented" % script_type) + return null + + +static func load_tests(resource_path: String) -> Dictionary: + var script := load_gd_script(resource_path) + var discovered_tests := {} + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + discovered_tests[test.display_name] = test + ) + + return discovered_tests + + +static func load_test_suite_gd(resource_path: String) -> GdUnitTestSuite: + var script := load_gd_script(resource_path) + var discovered_tests: Array[GdUnitTestCase] = [] + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + discovered_tests.append(test) + ) + # complete test suite wiht parsed test cases + return GdUnitTestSuiteScanner.new().load_suite(script, discovered_tests) + + +static func load_test_suite_cs(resource_path: String) -> Node: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + var script :Script = ClassDB.instantiate("CSharpScript") + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + script.resource_path = resource_path + script.reload() + return null + + +static func load_cs_script(resource_path: String, debug_write := false) -> Script: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + var script :Script = ClassDB.instantiate("CSharpScript") + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + var script_resource_path := resource_path.replace(resource_path.get_extension(), "cs") + if debug_write: + script_resource_path = GdUnitFileAccess.create_temp_dir("test") + "/%s" % script_resource_path.get_file() + print_debug("save resource:", script_resource_path) + DirAccess.remove_absolute(script_resource_path) + var err := ResourceSaver.save(script, script_resource_path) + if err != OK: + print_debug("Can't save debug resource",script_resource_path, "Error:", error_string(err)) + script.take_over_path(script_resource_path) + else: + script.take_over_path(resource_path) + script.reload() + return script + + +static func load_gd_script(resource_path: String, debug_write := false) -> GDScript: + # grap current level + var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access") + # disable and load the script + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0) + + var script := GDScript.new() + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + var script_resource_path := resource_path.replace(resource_path.get_extension(), "gd") + if debug_write: + script_resource_path = script_resource_path.replace("res://", GdUnitFileAccess.temp_dir() + "/") + #print_debug("save resource: ", script_resource_path) + DirAccess.remove_absolute(script_resource_path) + DirAccess.make_dir_recursive_absolute(script_resource_path.get_base_dir()) + var err := ResourceSaver.save(script, script_resource_path, ResourceSaver.FLAG_REPLACE_SUBRESOURCE_PATHS) + if err != OK: + print_debug("Can't save debug resource", script_resource_path, "Error:", error_string(err)) + script.take_over_path(script_resource_path) + else: + script.take_over_path(resource_path) + var error := script.reload() + if error != OK: + push_error("Errors on loading script %s. Error: %s" % [resource_path, error_string(error)]) + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access) + return script + #@warning_ignore("unsafe_cast") diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid new file mode 100644 index 0000000..dbf6706 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid @@ -0,0 +1 @@ +uid://dth63om1kgmwc diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd new file mode 100644 index 0000000..97a49b0 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd @@ -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) diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid new file mode 100644 index 0000000..7143f81 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid @@ -0,0 +1 @@ +uid://bxpdkva8owdce diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd new file mode 100644 index 0000000..345b7ed --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd @@ -0,0 +1,410 @@ +class_name GdUnitTestSuiteScanner +extends RefCounted + +const TEST_FUNC_TEMPLATE =""" + +func test_${func_name}() -> void: + # remove this line and complete your test + assert_not_yet_implemented() +""" + + +# we exclude the gdunit source directorys by default +const exclude_scan_directories = [ + "res://addons/gdUnit4/bin", + "res://addons/gdUnit4/src", + "res://reports"] + + +const ARGUMENT_TIMEOUT := "timeout" +const ARGUMENT_SKIP := "do_skip" +const ARGUMENT_SKIP_REASON := "skip_reason" +const ARGUMENT_PARAMETER_SET := "test_parameters" + + +var _script_parser := GdScriptParser.new() +var _included_resources: PackedStringArray = [] +var _excluded_resources: PackedStringArray = [] +var _expression_runner := GdUnitExpressionRunner.new() +var _regex_extends_clazz_name := RegEx.create_from_string("extends[\\s]+([\\S]+)") + + +func prescan_testsuite_classes() -> void: + # scan and cache extends GdUnitTestSuite by class name an resource paths + var script_classes: Array[Dictionary] = ProjectSettings.get_global_class_list() + for script_meta in script_classes: + var base_class: String = script_meta["base"] + var resource_path: String = script_meta["path"] + if base_class == "GdUnitTestSuite": + @warning_ignore("return_value_discarded") + _included_resources.append(resource_path) + elif ClassDB.class_exists(base_class): + @warning_ignore("return_value_discarded") + _excluded_resources.append(resource_path) + + +func scan(resource_path: String) -> Array[Script]: + prescan_testsuite_classes() + # if single testsuite requested + if FileAccess.file_exists(resource_path): + var test_suite := _load_is_test_suite(resource_path) + if test_suite != null: + return [test_suite] + return [] + return scan_directory(resource_path) + + +func scan_directory(resource_path: String) -> Array[Script]: + prescan_testsuite_classes() + # We use the global cache to fast scan for test suites. + if _excluded_resources.has(resource_path): + return [] + + var base_dir := DirAccess.open(resource_path) + if base_dir == null: + prints("Given directory or file does not exists:", resource_path) + return [] + + prints("Scanning for test suites in:", resource_path) + return _scan_test_suites_scripts(base_dir, []) + + +func _scan_test_suites_scripts(dir: DirAccess, collected_suites: Array[Script]) -> Array[Script]: + # Skip excluded directories + if dir.file_exists(".gdignore"): + prints("Exclude directory %s, containing .gdignore file" % dir.get_current_dir()) + return [] + + if exclude_scan_directories.has(dir.get_current_dir()): + return collected_suites + + var err := dir.list_dir_begin() + if err != OK: + push_error("Error on scanning directory %s" % dir.get_current_dir(), error_string(err)) + return collected_suites + var file_name := dir.get_next() + while file_name != "": + var resource_path := GdUnitTestSuiteScanner._file(dir, file_name) + if dir.current_is_dir(): + var sub_dir := DirAccess.open(resource_path) + if sub_dir != null: + @warning_ignore("return_value_discarded") + _scan_test_suites_scripts(sub_dir, collected_suites) + else: + var time := LocalTime.now() + var test_suite := _load_is_test_suite(resource_path) + if test_suite: + collected_suites.append(test_suite) + if OS.is_stdout_verbose() and time.elapsed_since_ms() > 300: + push_warning("Scanning of test-suite '%s' took more than 300ms: " % resource_path, time.elapsed_since()) + file_name = dir.get_next() + return collected_suites + + +static func _file(dir: DirAccess, file_name: String) -> String: + var current_dir := dir.get_current_dir() + if current_dir.ends_with("/"): + return current_dir + file_name + return current_dir + "/" + file_name + + +func _load_is_test_suite(resource_path: String) -> Script: + if not GdUnitTestSuiteScanner._is_script_format_supported(resource_path): + return null + + # We use the global cache to fast scan for test suites. + if _excluded_resources.has(resource_path): + return null + # Check in the global class cache whether the GdUnitTestSuite class has been extended. + if _included_resources.has(resource_path): + return GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + + # Otherwise we need to scan manual, we need to exclude classes where direct extends form Godot classes + # the resource loader can fail to load e.g. plugin classes with do preload other scripts + #var extends_from := get_extends_classname(resource_path) + # If not extends is defined or extends from a Godot class + #if extends_from.is_empty() or ClassDB.class_exists(extends_from): + # return null + # Finally, we need to load the class to determine it is a test suite + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + if not is_test_suite(script): + return null + return script + + +func load_suite(script: GDScript, tests: Array[GdUnitTestCase]) -> GdUnitTestSuite: + var test_suite: GdUnitTestSuite = script.new() + var first_test: GdUnitTestCase = tests.front() + test_suite.set_name(first_test.suite_name) + + # We need to group first all parameterized tests together to load the parameter set once + var grouped_by_test := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String: + return test.test_name + ) + # Extract function descriptors + var test_names: PackedStringArray = grouped_by_test.keys() + test_names.append("before") + var function_descriptors := _script_parser.get_function_descriptors(script, test_names) + + # Convert to test + for fd in function_descriptors: + if fd.name() == "before": + _handle_test_suite_arguments(test_suite, script, fd) + continue + + # Build test attributes from test method + var test_attribute := _build_test_attribute(script, fd) + # Create test from descriptor and given attributes + var test_group: Array = grouped_by_test[fd.name()] + for test: GdUnitTestCase in test_group: + # We need a copy, because of mutable state + var attribute: TestCaseAttribute = test_attribute.clone() + test_suite.add_child(_TestCase.new(test, attribute, fd)) + return test_suite + + +func _build_test_attribute(script: GDScript, fd: GdFunctionDescriptor) -> TestCaseAttribute: + var collected_unknown_aruments := PackedStringArray() + var attribute := TestCaseAttribute.new() + + # Collect test attributes + for arg: GdFunctionArgument in fd.args(): + if arg.type() == GdObjects.TYPE_FUZZER: + attribute.fuzzers.append(arg) + else: + # We allow underscore as prefix to prevent unused argument warnings + match arg.name().trim_prefix("_"): + ARGUMENT_TIMEOUT: + attribute.timeout = type_convert(arg.default(), TYPE_INT) + ARGUMENT_SKIP: + var result: Variant = _expression_runner.execute(script, arg.plain_value()) + if result is bool: + attribute.is_skipped = result + else: + push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value()) + ARGUMENT_SKIP_REASON: + attribute.skip_reason = arg.plain_value() + Fuzzer.ARGUMENT_ITERATIONS: + attribute.fuzzer_iterations = type_convert(arg.default(), TYPE_INT) + Fuzzer.ARGUMENT_SEED: + attribute.test_seed = type_convert(arg.default(), TYPE_INT) + ARGUMENT_PARAMETER_SET: + collected_unknown_aruments.clear() + pass + _: + collected_unknown_aruments.append(arg.name()) + + # Verify for unknown arguments + if not collected_unknown_aruments.is_empty(): + attribute.is_skipped = true + attribute.skip_reason = "Unknown test case argument's %s found." % collected_unknown_aruments + + return attribute + + +# We load the test suites with disabled unsafe_method_access to avoid spamming loading errors +# `unsafe_method_access` will happen when using `assert_that` +static func load_with_disabled_warnings(resource_path: String) -> Script: + # grap current level + var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access") + + # disable and load the script + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0) + + var script: Script = ( + GdUnitTestResourceLoader.load_gd_script(resource_path) if resource_path.ends_with("resource") + else ResourceLoader.load(resource_path)) + + # restore + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access) + return script + + +static func is_test_suite(script: Script) -> bool: + if script is GDScript: + var stack := [script] + while not stack.is_empty(): + var current: Script = stack.pop_front() + var base: Script = current.get_base_script() + if base != null: + if base.resource_path.find("GdUnitTestSuite") != -1: + return true + stack.push_back(base) + elif script != null and script.get_class() == "CSharpScript": + return true + return false + + +static func _is_script_format_supported(resource_path: String) -> bool: + var ext := resource_path.get_extension() + return ext == "gd" or ext == "cs" + + +static func parse_test_suite_name(script: Script) -> String: + return script.resource_path.get_file().replace(".gd", "") + + +func _handle_test_suite_arguments(test_suite: GdUnitTestSuite, script: GDScript, fd: GdFunctionDescriptor) -> void: + for arg in fd.args(): + # We allow underscore as prefix to prevent unused argument warnings + match arg.name().trim_prefix("_"): + ARGUMENT_SKIP: + var result: Variant = _expression_runner.execute(script, arg.plain_value()) + if result is bool: + test_suite.__is_skipped = result + else: + push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value()) + ARGUMENT_SKIP_REASON: + test_suite.__skip_reason = arg.plain_value() + _: + push_error("Unsuported argument `%s` found on before() at '%s'!" % [arg.name(), script.resource_path]) + + +# converts given file name by configured naming convention +static func _to_naming_convention(file_name: String) -> String: + var nc :int = GdUnitSettings.get_setting(GdUnitSettings.TEST_SUITE_NAMING_CONVENTION, 0) + match nc: + GdUnitSettings.NAMING_CONVENTIONS.AUTO_DETECT: + if GdObjects.is_snake_case(file_name): + return GdObjects.to_snake_case(file_name + "Test") + return GdObjects.to_pascal_case(file_name + "Test") + GdUnitSettings.NAMING_CONVENTIONS.SNAKE_CASE: + return GdObjects.to_snake_case(file_name + "Test") + GdUnitSettings.NAMING_CONVENTIONS.PASCAL_CASE: + return GdObjects.to_pascal_case(file_name + "Test") + push_error("Unexpected case") + return "-<Unexpected>-" + + +static func resolve_test_suite_path(source_script_path: String, test_root_folder: String = "test") -> String: + var file_name := source_script_path.get_basename().get_file() + var suite_name := _to_naming_convention(file_name) + if test_root_folder.is_empty() or test_root_folder == "/": + return source_script_path.replace(file_name, suite_name) + + # is user tmp + if source_script_path.begins_with("user://tmp"): + return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name) + + # at first look up is the script under a "src" folder located + var test_suite_path: String + var src_folder := source_script_path.find("/src/") + if src_folder != -1: + test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/") + else: + var paths := source_script_path.split("/", false) + # is a plugin script? + if paths[1] == "addons": + test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder] + # rebuild plugin path + for index in range(3, paths.size()): + test_suite_path += "/" + paths[index] + else: + test_suite_path = paths[0] + "//" + test_root_folder + for index in range(1, paths.size()): + test_suite_path += "/" + paths[index] + return normalize_path(test_suite_path).replace(file_name, suite_name) + + +static func normalize_path(path: String) -> String: + return path.replace("///", "/") + + +static func create_test_suite(test_suite_path: String, source_path: String) -> GdUnitResult: + # create directory if not exists + if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()): + var error_ := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir()) + if error_ != OK: + return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error_]) + var script := GDScript.new() + script.source_code = GdUnitTestSuiteTemplate.build_template(source_path) + var error := ResourceSaver.save(script, test_suite_path) + if error != OK: + return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error]) + return GdUnitResult.success(test_suite_path) + + +static func get_test_case_line_number(resource_path: String, func_name: String) -> int: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file != null: + var line_number := 0 + while not file.eof_reached(): + var row := file.get_line() + line_number += 1 + # ignore comments and empty lines and not test functions + if row.begins_with("#") || row.length() == 0 || row.find("func test_") == -1: + continue + # abort if test case name found + if row.find("func") != -1 and row.find("test_" + func_name) != -1: + return line_number + return -1 + + +func get_extends_classname(resource_path: String) -> String: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file != null: + while not file.eof_reached(): + var row := file.get_line() + # skip comments and empty lines + if row.begins_with("#") || row.length() == 0: + continue + # Stop at first function + if row.contains("func"): + return "" + var result := _regex_extends_clazz_name.search(row) + if result != null: + return result.get_string(1) + return "" + + +static func add_test_case(resource_path: String, func_name: String) -> GdUnitResult: + var script := load_with_disabled_warnings(resource_path) + # count all exiting lines and add two as space to add new test case + var line_number := count_lines(script) + 2 + var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name) + if Engine.is_editor_hint(): + # NOTE: Avoid using EditorInterface and EditorSettings directly, + # as it causes compilation errors in exported projects. + @warning_ignore_start("unsafe_method_access") + var editor_interface: Object = Engine.get_singleton("EditorInterface") + var settings: Object = editor_interface.get_editor_settings() + var ident_type: int = settings.get_setting("text_editor/behavior/indent/type") + var ident_size: int = settings.get_setting("text_editor/behavior/indent/size") + @warning_ignore_restore("unsafe_method_access") + if ident_type == 1: + func_body = func_body.replace(" ", "".lpad(ident_size, " ")) + script.source_code += func_body + var error := ResourceSaver.save(script, resource_path) + if error != OK: + return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error]) + return GdUnitResult.success({ "path" : resource_path, "line" : line_number}) + + +static func count_lines(script: Script) -> int: + return script.source_code.split("\n").size() + + +static func test_suite_exists(test_suite_path: String) -> bool: + return FileAccess.file_exists(test_suite_path) + + +static func test_case_exists(test_suite_path :String, func_name :String) -> bool: + if not test_suite_exists(test_suite_path): + return false + var script := load_with_disabled_warnings(test_suite_path) + for f in script.get_script_method_list(): + if f["name"] == "test_" + func_name: + return true + return false + + +static func create_test_case(test_suite_path: String, func_name: String, source_script_path: String) -> GdUnitResult: + if test_case_exists(test_suite_path, func_name): + var line_number := get_test_case_line_number(test_suite_path, func_name) + return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number}) + + if not test_suite_exists(test_suite_path): + var result := create_test_suite(test_suite_path, source_script_path) + if result.is_error(): + return result + return add_test_case(test_suite_path, func_name) diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid new file mode 100644 index 0000000..223b357 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid @@ -0,0 +1 @@ +uid://crsmt5amypl65 diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd b/addons/gdUnit4/src/core/GdUnitTools.gd new file mode 100644 index 0000000..0742896 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTools.gd @@ -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() diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd.uid b/addons/gdUnit4/src/core/GdUnitTools.gd.uid new file mode 100644 index 0000000..af02f0d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTools.gd.uid @@ -0,0 +1 @@ +uid://wmir42v6nqmf diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd b/addons/gdUnit4/src/core/GodotVersionFixures.gd new file mode 100644 index 0000000..5299325 --- /dev/null +++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd @@ -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 diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid new file mode 100644 index 0000000..5b874df --- /dev/null +++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid @@ -0,0 +1 @@ +uid://b0kdldwgufklw diff --git a/addons/gdUnit4/src/core/LocalTime.gd b/addons/gdUnit4/src/core/LocalTime.gd new file mode 100644 index 0000000..fabaaf6 --- /dev/null +++ b/addons/gdUnit4/src/core/LocalTime.gd @@ -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 diff --git a/addons/gdUnit4/src/core/LocalTime.gd.uid b/addons/gdUnit4/src/core/LocalTime.gd.uid new file mode 100644 index 0000000..ef275de --- /dev/null +++ b/addons/gdUnit4/src/core/LocalTime.gd.uid @@ -0,0 +1 @@ +uid://dq6srj6me3mjo diff --git a/addons/gdUnit4/src/core/_TestCase.gd b/addons/gdUnit4/src/core/_TestCase.gd new file mode 100644 index 0000000..2ef07dc --- /dev/null +++ b/addons/gdUnit4/src/core/_TestCase.gd @@ -0,0 +1,253 @@ +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 := false +var _terminated := 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 do_terminate() -> void: + _terminated = true + # We need to dispose manually the function state here + GdObjects.dispose_function_state(_func_state) + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + execution_context.add_report(GdUnitReport.new()\ + .create(GdUnitReport.TERMINATED, line_number(), GdAssertMessages.test_session_terminated())) + 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_terminated() -> bool: + return _terminated + + +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 + + +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] diff --git a/addons/gdUnit4/src/core/_TestCase.gd.uid b/addons/gdUnit4/src/core/_TestCase.gd.uid new file mode 100644 index 0000000..5c7e8b9 --- /dev/null +++ b/addons/gdUnit4/src/core/_TestCase.gd.uid @@ -0,0 +1 @@ +uid://byae5d27sl1jq diff --git a/addons/gdUnit4/src/core/assets/touch-button.png b/addons/gdUnit4/src/core/assets/touch-button.png Binary files differnew file mode 100644 index 0000000..23f46ef --- /dev/null +++ b/addons/gdUnit4/src/core/assets/touch-button.png diff --git a/addons/gdUnit4/src/core/assets/touch-button.png.import b/addons/gdUnit4/src/core/assets/touch-button.png.import new file mode 100644 index 0000000..4122b48 --- /dev/null +++ b/addons/gdUnit4/src/core/assets/touch-button.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhtfmfhdtq4qr" +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] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd new file mode 100644 index 0000000..cf7a2b9 --- /dev/null +++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd @@ -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 diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid new file mode 100644 index 0000000..653a941 --- /dev/null +++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid @@ -0,0 +1 @@ +uid://cno711hbnapj1 diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd new file mode 100644 index 0000000..f5c5ffb --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd @@ -0,0 +1,64 @@ +@abstract class_name GdUnitBaseCommand +extends Node + + +var id: String +var icon: Texture2D +var shortcut: Shortcut = null +var shortcut_type: GdUnitShortcut.ShortCut + + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut = GdUnitShortcut.ShortCut.NONE) -> void: + id = p_id + shortcut_type = p_shortcut + _set_shortcut() + + +func _shortcut_input(event: InputEvent) -> void: + if is_running(): + return + + if shortcut and shortcut.matches_event(event): + execute() + get_viewport().set_input_as_handled() + + +func update_shortcut() -> void: + _set_shortcut() + + +func _set_shortcut() -> void: + if shortcut_type == GdUnitShortcut.ShortCut.NONE: + return + + var property_name := GdUnitShortcut.as_property(shortcut_type) + var property := GdUnitSettings.get_property(property_name) + var keys := GdUnitShortcut.default_keys(shortcut_type) + if property != null: + keys = property.value() + var inputEvent := _create_shortcut_input_even(keys) + + shortcut = Shortcut.new() + shortcut.set_events([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 + + +@abstract func is_running() -> bool + +@abstract func execute(...parameters: Array) -> void diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid new file mode 100644 index 0000000..da0e452 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid @@ -0,0 +1 @@ +uid://bbetqeco0l3rj diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd new file mode 100644 index 0000000..6a173c5 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd @@ -0,0 +1,42 @@ +@abstract class_name GdUnitCommandFileSystem +extends GdUnitBaseCommand + + +var _test_session_command: GdUnitCommandTestSession + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void: + super(p_id, p_shortcut) + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute_tests(paths: PackedStringArray, with_debug: bool) -> void: + var suite_scaner := GdUnitTestSuiteScanner.new() + var scripts: Array[Script] + + for resource_path in paths: + # directories and test-suites are valid to enable the menu + if DirAccess.dir_exists_absolute(resource_path): + scripts.append_array(suite_scaner.scan_directory(resource_path)) + continue + + var file_type := resource_path.get_extension() + if file_type == "gd" or file_type == "cs": + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + + if GdUnitTestSuiteScanner.is_test_suite(script): + scripts.append(script) + + 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) + _test_session_command.execute(tests_to_execute, with_debug) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid new file mode 100644 index 0000000..fd5b2c3 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid @@ -0,0 +1 @@ +uid://lo83wc48tvgp diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd new file mode 100644 index 0000000..c4c553c --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd @@ -0,0 +1,17 @@ +class_name GdUnitCommandFileSystemDebugTests +extends GdUnitCommandFileSystem + + +const ID := "Debug FileSystem Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG, test_session_command) + icon = GdUnitUiTools.get_icon("PlayStart") + + +func execute(...parameters: Array) -> void: + if parameters.is_empty(): + return + var selected_paths: PackedStringArray = parameters[0] + execute_tests(selected_paths, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid new file mode 100644 index 0000000..24e7925 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid @@ -0,0 +1 @@ +uid://beoj16ng5y3di diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd new file mode 100644 index 0000000..d17413f --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd @@ -0,0 +1,15 @@ +class_name GdUnitCommandFileSystemRunTests +extends GdUnitCommandFileSystem + + +const ID := "Run FileSystem Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE, test_session_command) + icon = GdUnitUiTools.get_icon("Play") + + +func execute(...parameters: Array) -> void: + var selected_paths: PackedStringArray = parameters[0] + execute_tests(selected_paths, false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid new file mode 100644 index 0000000..62b55e6 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid @@ -0,0 +1 @@ +uid://iq85jvyaa48b diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd new file mode 100644 index 0000000..8e2b2f3 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd @@ -0,0 +1,126 @@ +class_name GdUnitCommandHandler +extends Object + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _commnand_mappings: Dictionary[String, GdUnitBaseCommand]= {} +var test_session_command := GdUnitCommandTestSession.new() + +static func instance() -> GdUnitCommandHandler: + return GdUnitSingleton.instance("GdUnitCommandHandler", func() -> GdUnitCommandHandler: return GdUnitCommandHandler.new()) + + +@warning_ignore("return_value_discarded") +func _init() -> void: + GdUnitSignals.instance().gdunit_event.connect(_on_event) + GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_client_disconnected) + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + + _register_command(test_session_command) + _register_command(GdUnitCommandStopTestSession.new(test_session_command)) + _register_command(GdUnitCommandInspectorRunTests.new(test_session_command)) + _register_command(GdUnitCommandInspectorDebugTests.new(test_session_command)) + _register_command(GdUnitCommandInspectorRerunTestsUntilFailure.new(test_session_command)) + _register_command(GdUnitCommandInspectorTreeCollapse.new()) + _register_command(GdUnitCommandInspectorTreeExpand.new()) + _register_command(GdUnitCommandScriptEditorRunTests.new(test_session_command)) + _register_command(GdUnitCommandScriptEditorDebugTests.new(test_session_command)) + _register_command(GdUnitCommandScriptEditorCreateTest.new()) + _register_command(GdUnitCommandFileSystemRunTests.new(test_session_command)) + _register_command(GdUnitCommandFileSystemDebugTests.new(test_session_command)) + _register_command(GdUnitCommandRunTestsOverall.new(test_session_command)) + + # 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: + for command: GdUnitBaseCommand in _commnand_mappings.values(): + if Engine.is_editor_hint(): + EditorInterface.get_command_palette().remove_command("GdUnit4/"+command.id) + command.free() + _commnand_mappings.clear() + + +func _do_process() -> void: + # Do stop test execution when the user has stoped the main scene manually + if test_session_command._is_debug and test_session_command.is_running() and not EditorInterface.is_playing_scene(): + if GdUnitSettings.is_verbose_assert_warnings(): + print_debug("Test Runner scene was stopped manually, force stopping the current test run!") + command_execute(GdUnitCommandStopTestSession.ID) + + +func command_icon(command_id: String) -> Texture2D: + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_icon(): No command id '%s' is registered." % command_id) + print_stack() + return + return _commnand_mappings[command_id].icon + + +func command_shortcut(command_id: String) -> Shortcut: + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_shortcut(): No command id '%s' is registered." % command_id) + print_stack() + return + return _commnand_mappings[command_id].shortcut + + +func command_execute(...parameters: Array) -> void: + if parameters.is_empty(): + push_error("Invalid arguments used on CommandHandler:execute()! Expecting [<command_id, args...>]") + print_stack() + return + + var command_id: String = parameters.pop_front() + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_execute(): No command id '%s' is registered." % command_id) + print_stack() + return + await _commnand_mappings[command_id].callv("execute", parameters) + + +func _register_command(command: GdUnitBaseCommand) -> void: + # first verify the command is not already registerd + if _commnand_mappings.has(command.id): + push_error("GdUnitCommandHandler:_register_command(): Command with id '%s' is already registerd!" % command.id) + return + + _commnand_mappings[command.id] = command + if Engine.is_editor_hint(): + EditorInterface.get_base_control().add_child(command) + EditorInterface.get_command_palette().add_command(command.id, "GdUnit4/"+command.id, command.execute, command.shortcut.get_as_text() if command.shortcut else "None") + + +func cmd_discover_tests() -> void: + await GdUnitTestDiscoverer.run() + + +################################################################################ +# signals handles +################################################################################ +func _on_event(event: GdUnitEvent) -> void: + if event.type() == GdUnitEvent.SESSION_CLOSE: + command_execute(GdUnitCommandStopTestSession.ID) + + +func _on_settings_changed(property: GdUnitProperty) -> void: + for command: GdUnitBaseCommand in _commnand_mappings.values(): + command.update_shortcut() + + 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_disconnected(_client_id: int) -> void: + command_execute(GdUnitCommandStopTestSession.ID) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid new file mode 100644 index 0000000..1a34d39 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid @@ -0,0 +1 @@ +uid://m5es1m3vrm50 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd new file mode 100644 index 0000000..cb4a634 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd @@ -0,0 +1,27 @@ +class_name GdUnitCommandInspectorDebugTests +extends GdUnitBaseCommand + +const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd") +const ID := "Debug Inspector Tests" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG) + icon = GdUnitUiTools.get_icon("PlayStart") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + + _test_session_command.execute(tests_to_execute, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid new file mode 100644 index 0000000..fd8150f --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid @@ -0,0 +1 @@ +uid://c7qpkaykv4f4a diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd new file mode 100644 index 0000000..e2ec5e4 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd @@ -0,0 +1,57 @@ +class_name GdUnitCommandInspectorRerunTestsUntilFailure +extends GdUnitBaseCommand + + +signal session_closed() + + +const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd") +const ID := "Rerun Inspector Tests Until Failure" + + +var _test_session_command: GdUnitCommandTestSession +var _current_execution_count := 0 + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE) + icon = GdUnitUiTools.get_icon("Play") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + var rerun_until_failure_count := GdUnitSettings.get_rerun_max_retries() + var saved_settings: bool = ProjectSettings.get_setting(GdUnitSettings.TEST_FLAKY_CHECK) + ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, false) + + GdUnitSignals.instance().gdunit_event.connect(_on_test_event) + _current_execution_count = 0 + + _test_session_command._is_fail_fast = true + while _current_execution_count < rerun_until_failure_count: + _test_session_command.execute(tests_to_execute, true) + await session_closed + _test_session_command._is_fail_fast = false + + ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, saved_settings) + GdUnitSignals.instance().gdunit_event.disconnect(_on_test_event) + + +func _on_test_event(event: GdUnitEvent) -> void: + if event.type() == GdUnitEvent.SESSION_START: + _current_execution_count += 1 + GdUnitSignals.instance().gdunit_message.emit("[color=RED]Execution Mode: ReRun until failure! (iteration %d)[/color]" % _current_execution_count) + if event.type() == GdUnitEvent.SESSION_CLOSE: + session_closed.emit() + if event.type() == GdUnitEvent.TESTCASE_AFTER: + if not event.is_success(): + GdUnitSignals.instance().gdunit_message.emit(" [color=RED](iteration: %d)[/color]" % _current_execution_count) + _current_execution_count = 9999 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid new file mode 100644 index 0000000..d589f76 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid @@ -0,0 +1 @@ +uid://d0pvhydsl86mf diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd new file mode 100644 index 0000000..c0cb733 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd @@ -0,0 +1,26 @@ +class_name GdUnitCommandInspectorRunTests +extends GdUnitBaseCommand + +const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd") +const ID := "Run Inspector Tests" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS) + icon = GdUnitUiTools.get_icon("Play") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + _test_session_command.execute(tests_to_execute, false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid new file mode 100644 index 0000000..2d3aee1 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid @@ -0,0 +1 @@ +uid://dcrjckjo7ur1g diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd new file mode 100644 index 0000000..fa784f8 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd @@ -0,0 +1,26 @@ +class_name GdUnitCommandInspectorTreeCollapse +extends GdUnitBaseCommand + +const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd") +const ID := "Inspector Tree Collapse" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + icon = GdUnitUiTools.get_icon("CollapseTree") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> void: + var inspector: InspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + if selected_item == null: + selected_item = inspector._tree.get_root() + else: + selected_item = selected_item.get_parent() + + inspector.do_collapse_all(false, selected_item) + inspector.do_collapse_all(true, selected_item) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid new file mode 100644 index 0000000..810921d --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid @@ -0,0 +1 @@ +uid://c1loc8h46b4m0 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd new file mode 100644 index 0000000..76f8d0b --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd @@ -0,0 +1,25 @@ +class_name GdUnitCommandInspectorTreeExpand +extends GdUnitBaseCommand + +const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd") +const ID := "Inspector Tree Expand" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + icon = GdUnitUiTools.get_icon("ExpandTree") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> void: + var inspector: InspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + if selected_item == null: + selected_item = inspector._tree.get_root() + else: + selected_item = selected_item.get_parent() + + inspector.do_collapse_all(false, selected_item) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid new file mode 100644 index 0000000..4f3fad5 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid @@ -0,0 +1 @@ +uid://dc1uaxrnrvesy diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd new file mode 100644 index 0000000..daf534b --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd @@ -0,0 +1,22 @@ +class_name GdUnitCommandRunTestsOverall +extends GdUnitBaseCommand + +const ID := "Run Tests Overall" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL) + icon = GdUnitUiTools.get_run_overall_icon() + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var tests_to_execute := await GdUnitTestDiscoverer.run() + _test_session_command.execute(tests_to_execute, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid new file mode 100644 index 0000000..4e38afe --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid @@ -0,0 +1 @@ +uid://b2oyb1jkqagjc diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd new file mode 100644 index 0000000..5504fa7 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd @@ -0,0 +1,56 @@ +@abstract class_name GdUnitCommandScriptEditor +extends GdUnitBaseCommand + +var _test_session_command: GdUnitCommandTestSession + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void: + super(p_id, p_shortcut) + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute_tests(with_debug: bool) -> void: + var selected_tests := PackedStringArray() + 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_"): + selected_tests.append(func_name) + + var tests_to_execute := _collect_tests(_active_script(), selected_tests) + _test_session_command.execute(tests_to_execute, with_debug) + + +func _collect_tests(script: Script, tests: PackedStringArray) -> Array[GdUnitTestCase]: + # Update test discovery + var tests_to_execute: Array[GdUnitTestCase] = [] + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new()) + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + if tests.is_empty() or tests.has(test.test_name): + 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) + return tests_to_execute + + +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() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid new file mode 100644 index 0000000..619f309 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid @@ -0,0 +1 @@ +uid://b1ftsa2a1ba1i diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd new file mode 100644 index 0000000..17876ca --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd @@ -0,0 +1,41 @@ +class_name GdUnitCommandScriptEditorCreateTest +extends GdUnitBaseCommand + + +const ID := "Create Test" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.CREATE_TEST) + icon = GdUnitUiTools.get_icon("New") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> 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 _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() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid new file mode 100644 index 0000000..fb6d1f9 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid @@ -0,0 +1 @@ +uid://dikmhnnpgxyhs diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd new file mode 100644 index 0000000..8ab9f24 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd @@ -0,0 +1,14 @@ +class_name GdUnitCommandScriptEditorDebugTests +extends GdUnitCommandScriptEditor + + +const ID := "Debug ScriptEditor Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG, test_session_command) + icon = GdUnitUiTools.get_icon("PlayStart") + + +func execute(..._parameters: Array) -> void: + execute_tests(true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid new file mode 100644 index 0000000..e575fb8 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid @@ -0,0 +1 @@ +uid://bthuj4vyx2erm diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd new file mode 100644 index 0000000..a18851a --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd @@ -0,0 +1,14 @@ +class_name GdUnitCommandScriptEditorRunTests +extends GdUnitCommandScriptEditor + + +const ID := "Run ScriptEditor Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE, test_session_command) + icon = GdUnitUiTools.get_icon("Play") + + +func execute(..._parameters: Array) -> void: + execute_tests(false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid new file mode 100644 index 0000000..4889e26 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid @@ -0,0 +1 @@ +uid://c5w4h304l54c6 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd new file mode 100644 index 0000000..50a6df7 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd @@ -0,0 +1,21 @@ +class_name GdUnitCommandStopTestSession +extends GdUnitBaseCommand + +const ID := "Stop Test Session" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.STOP_TEST_RUN) + icon = GdUnitUiTools.get_icon("Stop") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + await _test_session_command.stop() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid new file mode 100644 index 0000000..a799bf6 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid @@ -0,0 +1 @@ +uid://b33mmwenpaj4m diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd new file mode 100644 index 0000000..53d74ed --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd @@ -0,0 +1,124 @@ +class_name GdUnitCommandTestSession +extends GdUnitBaseCommand + + +const ID := "Start Test Session" + + +var _current_runner_process_id: int +var _is_running: bool +var _is_debug: bool +var _is_fail_fast: bool + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + _is_running = false + _is_fail_fast = false + + +func is_running() -> bool: + return _is_running + + +func stop() -> void: + if not is_running(): + return + _is_running = false + + if _is_debug: + force_pause_scene() + + GdUnitSignals.instance().gdunit_test_session_terminate.emit() + # Give the API time to commit terminate to the client + await get_tree().create_timer(.5).timeout + + if _is_debug and EditorInterface.is_playing_scene(): + EditorInterface.stop_playing_scene() + # We need finaly to send the test session close event because the current run is hard aborted. + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new()) + elif 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 + # We need finaly to send the test session close event because the current run is hard aborted. + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new()) + + +## Forces the running scene to unpause when the debugger hits a breakpoint.[br] +## [br] +## When the Godot debugger stops at a breakpoint during test execution, it blocks[br] +## the main thread. This prevents signals and TCP communications from being processed,[br] +## which can cause GdUnit4 tests to hang or fail to communicate properly with the[br] +## test runner. This function programmatically unpauses the scene to restore[br] +## main thread execution while maintaining debugger functionality. [br] +## [br] +## [b]Technical Background:[/b][br] +## - Debugger breakpoints freeze the main thread to allow inspection[br] +## - Frozen main thread blocks signal processing and network communications[br] +## - GdUnit4 requires active signal/TCP processing for test coordination[br] +## - This function finds and triggers the editor's pause button to resume execution[br] +## [br] +## [b]How It Works:[/b][br] +## 1. Locates the EditorRunBar in the Godot editor UI hierarchy[br] +## 2. Searches for the pause button by matching its icon[br] +## 3. Unpresses the button if it's currently pressed (paused state)[br] +## 4. Manually triggers the button's connected callbacks to resume execution[br] +func force_pause_scene() -> bool: + var nodes := EditorInterface.get_base_control().find_children("*", "EditorRunBar", true, false) + if nodes.size() != 1: + push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'") + return false + var editor_run_bar := nodes[0] + var containers := editor_run_bar.find_children("*", "HBoxContainer", true, false) + var pause_icon := GdUnitUiTools.get_icon("Pause") + + for container in containers: + for child in container.get_children(): + if child is Button: + var button: Button = child + if pause_icon == button.icon: + button.set_pressed(false) + + var connected_signals := button.get_signal_connection_list("pressed") + if not connected_signals.is_empty(): + for signal_ in connected_signals: + var cb: Callable = signal_["callable"] + cb.call() + return true + push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'") + return false + + +func execute(...parameters: Array) -> void: + var tests_to_execute: Array[GdUnitTestCase] = parameters[0] + _is_debug = parameters[1] + + _prepare_test_session(tests_to_execute) + if _is_debug: + EditorInterface.play_custom_scene("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn") + else: + 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 _prepare_test_session(tests_to_execute: Array[GdUnitTestCase]) -> void: + var server_port: int = Engine.get_meta("gdunit_server_port") + var result := GdUnitRunnerConfig.new() \ + .set_server_port(server_port) \ + .do_fail_fast(_is_fail_fast) \ + .add_test_cases(tests_to_execute) \ + .save_config() + if result.is_error(): + push_error(result.error_message()) + return + # before start we have to save all scrpt changes + ScriptEditorControls.save_all_open_script() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid new file mode 100644 index 0000000..8e395e2 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid @@ -0,0 +1 @@ +uid://camw8m61ljvpl diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd new file mode 100644 index 0000000..33af082 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd @@ -0,0 +1,73 @@ +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, + RERUN_TESTS_UNTIL_FAILURE, + 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_ALT, Key.KEY_META, Key.KEY_F5], + ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_META, 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.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F5], + 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_ALT, Key.KEY_SHIFT, Key.KEY_F5], + ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_SHIFT, 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.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5], + ShortCut.CREATE_TEST : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F10], +} + + +const SETTINGS_MAPPING: Dictionary[ShortCut, String] = { + ShortCut.RUN_TESTCASE : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST, + ShortCut.RUN_TESTCASE_DEBUG : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST_DEBUG, + ShortCut.CREATE_TEST : GdUnitSettings.SHORTCUT_EDITOR_CREATE_TEST, + ShortCut.RERUN_TESTS : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST, + ShortCut.RERUN_TESTS_DEBUG : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, + ShortCut.RERUN_TESTS_UNTIL_FAILURE : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE, + ShortCut.STOP_TEST_RUN : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_STOP, + ShortCut.RUN_TESTS_OVERALL : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, + ShortCut.RUN_TESTSUITE : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST, + ShortCut.RUN_TESTSUITE_DEBUG : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG +} + + +static func as_property(sortcut: ShortCut) -> String: + return SETTINGS_MAPPING[sortcut] + + +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] diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid new file mode 100644 index 0000000..841bcaf --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid @@ -0,0 +1 @@ +uid://tyumhj8vfdaf diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd new file mode 100644 index 0000000..00332f9 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd @@ -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 diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid new file mode 100644 index 0000000..78730f9 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid @@ -0,0 +1 @@ +uid://caisrhebqar04 diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd new file mode 100644 index 0000000..766f9ea --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd @@ -0,0 +1,125 @@ +## GdUnitTestCase +## A class representing a single test case in GdUnit4. +## This class is used as a data container to hold all relevant information about a test case, +## including its location, dependencies, and metadata for test discovery and execution. + +class_name GdUnitTestCase +extends RefCounted + +## A unique identifier for the test case. Used to track and reference specific test instances. +var guid := GdUnitGUID.new() + +## The resource path to the test suite +var suite_resource_path: String + +## The name of the test method/function. Should start with "test_" prefix. +var test_name: String + +## The class name of the test suite containing this test case. +var suite_name: String + +## The fully qualified name of the test case following C# namespace pattern: +## Constructed from the folder path (where folders are dot-separated), the test suite name, and the test case name. +## All parts are joined by dots: {folder1.folder2.folder3}.{suite_name}.{test_name} +var fully_qualified_name: String + +var display_name: String + +## Index tracking test attributes for ordered execution. Default is 0. +## Higher values indicate later execution in the test sequence. +var attribute_index: int + +## Flag indicating if this test requires the Godot runtime environment. +## Tests requiring runtime cannot be executed in isolation. +var require_godot_runtime: bool = true + +## The path to the source file containing this test case. +## Used for test discovery and execution. +var source_file: String + +## Optional holds the assembly location for C# tests +var assembly_location: String = "" + +## The line number where the test case is defined in the source file. +## Used for navigation and error reporting. +var line_number: int = -1 + +## Additional metadata about the test case, such as: +## - tags: Array[String] - Test categories/tags for filtering +## - timeout: int - Maximum execution time in milliseconds +## - skip: bool - Whether the test should be skipped +## - dependencies: Array[String] - Required test dependencies +var metadata: Dictionary = {} + + +static func from_dict(dict: Dictionary) -> GdUnitTestCase: + var test := GdUnitTestCase.new() + test.guid = GdUnitGUID.new(str(dict["guid"])) + test.suite_resource_path = dict["suite_resource_path"] if dict.has("suite_resource_path") else dict["source_file"] + test.suite_name = dict["managed_type"] + test.test_name = dict["test_name"] + test.display_name = dict["simple_name"] + test.fully_qualified_name = dict["fully_qualified_name"] + test.attribute_index = dict["attribute_index"] + test.source_file = dict["source_file"] + test.line_number = dict["line_number"] + test.require_godot_runtime = dict["require_godot_runtime"] + test.assembly_location = dict["assembly_location"] + return test + + +static func to_dict(test: GdUnitTestCase) -> Dictionary: + return { + "guid": test.guid._guid, + "suite_resource_path": test.suite_resource_path, + "managed_type": test.suite_name, + "test_name" : test.test_name, + "simple_name" : test.display_name, + "fully_qualified_name" : test.fully_qualified_name, + "attribute_index" : test.attribute_index, + "source_file" : test.source_file, + "line_number" : test.line_number, + "require_godot_runtime" : test.require_godot_runtime, + "assembly_location" : test.assembly_location + } + + +static func from(_suite_resource_path: String, _source_file: String, _line_number: int, _test_name: String, _attribute_index := -1, _test_parameters := "") -> GdUnitTestCase: + if(_source_file == null or _source_file.is_empty()): + prints(_test_name) + + assert(_test_name != null and not _test_name.is_empty(), "Precondition: The parameter 'test_name' is not set") + assert(_source_file != null and not _source_file.is_empty(), "Precondition: The parameter 'source_file' is not set") + + var test := GdUnitTestCase.new() + test.suite_resource_path = _suite_resource_path + test.test_name = _test_name + test.source_file = _source_file + test.line_number = _line_number + test.attribute_index = _attribute_index + test._build_suite_name() + test._build_display_name(_test_parameters) + test._build_fully_qualified_name(_suite_resource_path) + return test + + +func _build_suite_name() -> void: + suite_name = source_file.get_file().get_basename() + assert(suite_name != null and not suite_name.is_empty(), "Precondition: The parameter 'suite_name' can't be resolved") + + +func _build_display_name(_test_parameters: String) -> void: + if attribute_index == -1: + display_name = test_name + else: + display_name = "%s:%d (%s)" % [test_name, attribute_index, _test_parameters.trim_prefix("[").trim_suffix("]").replace('"', "'")] + + +func _build_fully_qualified_name(_resource_path: String) -> void: + var name_space := _resource_path.trim_prefix("res://").trim_suffix(".gd").trim_suffix(".cs").replace("/", ".") + + if attribute_index == -1: + fully_qualified_name = "%s.%s" % [name_space, test_name] + else: + fully_qualified_name = "%s.%s.%s" % [name_space, test_name, display_name] + assert(fully_qualified_name != null and not fully_qualified_name.is_empty(), "Precondition: The parameter 'fully_qualified_name' can't be resolved") diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid new file mode 100644 index 0000000..0edbaff --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid @@ -0,0 +1 @@ +uid://ct8h4c11708ha diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd new file mode 100644 index 0000000..6af8255 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd @@ -0,0 +1,323 @@ +## Guards and tracks test case changes during test discovery and file modifications.[br] +## [br] +## This guard maintains a cache of discovered tests to track changes between test runs and during[br] +## file modifications. It is optimized for performance using simple but effective test identity checks.[br] +## [br] +## Test Change Detection:[br] +## - Moved tests: The test implementation remains at a different line number[br] +## - Renamed tests: The test line position remains but the test name changed[br] +## - Deleted tests: A previously discovered test was removed[br] +## - Added tests: A new test was discovered[br] +## [br] +## Cache Management:[br] +## - Maintains test identity through unique GdUnitTestCase GUIDs[br] +## - Maps source files to their discovered test cases[br] +## - Tracks only essential metadata (line numbers, names) to minimize memory use[br] +## [br] +## Change Detection Strategy:[br] +## The guard uses a lightweight approach by comparing only line numbers and test names.[br] +## This avoids expensive operations like test content parsing or similarity checks.[br] +## [br] +## Event Handling:[br] +## - Emits events on test changes through GdUnitSignals[br] +## - Synchronizes cache with test discovery events[br] +## - Notifies UI about test changes[br] +## [br] +## Example usage:[br] +## [codeblock] +## # Create guard for tracking test changes +## var guard := GdUnitTestDiscoverGuard.new() +## +## # Connect to test discovery events +## GdUnitSignals.instance().gdunit_test_discovered.connect(guard.sync_test_added) +## +## # Discover tests and track changes +## await guard.discover(test_script) +## [/codeblock] +class_name GdUnitTestDiscoverGuard +extends Object + + + +static func instance() -> GdUnitTestDiscoverGuard: + return GdUnitSingleton.instance("GdUnitTestDiscoverGuard", func() -> GdUnitTestDiscoverGuard: + return GdUnitTestDiscoverGuard.new() + ) + + +## Maps source files to their discovered test cases.[br] +## [br] +## Key: Test suite source file path[br] +## Value: Array of [class GdUnitTestCase] instances +var _discover_cache := {} + + +## Tracks discovered test changes for debug purposes.[br] +## [br] +## Available in debug mode only. Contains dictionaries:[br] +## - changed_tests: Tests that were moved or renamed[br] +## - deleted_tests: Tests that were removed[br] +## - added_tests: New tests that were discovered +var _discovered_changes := {} + + +## Controls test change debug tracking.[br] +## [br] +## When true, maintains _discovered_changes for debugging.[br] +## Used primarily in tests to verify change detection. +var _is_debug := false + + +## Creates a new guard instance.[br] +## [br] +## [param is_debug] When true, enables change tracking for debugging. +func _init(is_debug := false) -> void: + _is_debug = is_debug + # Register for discovery events to sync the cache + @warning_ignore("return_value_discarded") + GdUnitSignals.instance().gdunit_test_discover_added.connect(sync_test_added) + GdUnitSignals.instance().gdunit_test_discover_deleted.connect(sync_test_deleted) + GdUnitSignals.instance().gdunit_test_discover_modified.connect(sync_test_modified) + GdUnitSignals.instance().gdunit_event.connect(handle_discover_events) + + +## Adds a discovered test to the cache.[br] +## [br] +## [param test_case] The test case to add to the cache. +func sync_test_added(test_case: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + test_cases.append(test_case) + + +## Removes a test from the cache.[br] +## [br] +## [param test_case] The test case to remove from the cache. +func sync_test_deleted(test_case: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + test_cases.erase(test_case) + + +## Updates a test from the cache.[br] +## [br] +## [param test_case] The test case to update from the cache. +func sync_test_modified(changed_test: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(changed_test.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + for test in test_cases: + if test.guid == changed_test.guid: + test.test_name = changed_test.test_name + test.display_name = changed_test.display_name + test.line_number = changed_test.line_number + break + + +## Handles test discovery events.[br] +## [br] +## Resets the cache when a new discovery starts.[br] +## [param event] The discovery event to handle. +func handle_discover_events(event: GdUnitEvent) -> void: + # reset the cache on fresh discovery + if event.type() == GdUnitEvent.DISCOVER_START: + _discover_cache = {} + + +## Registers a callback for discovered tests.[br] +## [br] +## Default sink writes to [class GdUnitTestDiscoverSink]. +static func default_discover_sink(test_case: GdUnitTestCase) -> void: + GdUnitTestDiscoverSink.discover(test_case) + + +## Finds a test case by its unique identifier.[br] +## [br] +## Searches through all cached test cases across all test suites[br] +## to find a test with the matching GUID.[br] +## [br] +## [param id] The GUID of the test to find[br] +## Returns the matching test case or null if not found. +func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase: + for test_sets: Array[GdUnitTestCase] in _discover_cache.values(): + for test in test_sets: + if test.guid.equals(id): + return test + + return null + + +func get_discovered_tests() -> Array[GdUnitTestCase]: + var discovered_tests: Array[GdUnitTestCase] = [] + for test_sets: Array[GdUnitTestCase] in _discover_cache.values(): + discovered_tests.append_array(test_sets) + return discovered_tests + + +## Discovers tests in a script and tracks changes.[br] +## [br] +## Handles both GDScript and C# test suites.[br] +## The guard maintains test identity through changes.[br] +## [br] +## [param script] The test script to analyze[br] +## [param discover_sink] Optional callback for test discovery events +func discover(script: Script, discover_sink: Callable = default_discover_sink) -> void: + # Verify the script has no errors before run test discovery + var result := script.reload(true) + if result != OK: + return + + if _is_debug: + _discovered_changes["changed_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + _discovered_changes["deleted_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + _discovered_changes["added_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + if GdUnitTestSuiteScanner.is_test_suite(script): + # for cs scripts we need to recomplie before discover new tests + if script.get_class() == "CSharpScript": + await rebuild_project(script) + + # rediscover all tests + var source_file := script.resource_path + var discovered_tests: Array[GdUnitTestCase] = [] + + GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void: + discovered_tests.append(test_case) + ) + + # The suite is never discovered, we add all discovered tests + if not _discover_cache.has(source_file): + for test_case in discovered_tests: + discover_sink.call(test_case) + return + + sync_moved_tests(source_file, discovered_tests) + sync_renamed_tests(source_file, discovered_tests) + sync_deleted_tests(source_file, discovered_tests) + sync_added_tests(source_file, discovered_tests, discover_sink) + + +## Synchronizes moved tests between discover cycles.[br] +## [br] +## A test is considered moved when:[br] +## - It has the same name[br] +## - But a different line number[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_moved_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + for discovered_test in discovered_tests: + # lookup in cache + var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_moved.bind(discovered_test)) + for test in original_tests: + # update the line_number + var line_number_before := test.line_number + test.line_number = discovered_test.line_number + GdUnitSignals.instance().gdunit_test_discover_modified.emit(test) + if _is_debug: + prints("-> moved test id:%s %s: line:(%d -> %d)" % [test.guid, test.display_name, line_number_before, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes renamed tests between discover cycles.[br] +## [br] +## A test is considered renamed when:[br] +## - It has the same line number[br] +## - But a different name[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_renamed_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + for discovered_test in discovered_tests: + # lookup in cache + var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_renamed.bind(discovered_test)) + for test in original_tests: + # update the renaming names + var original_display_name := test.display_name + test.test_name = discovered_test.test_name + test.display_name = discovered_test.display_name + GdUnitSignals.instance().gdunit_test_discover_modified.emit(test) + if _is_debug: + prints("-> renamed test id:%s %s -> %s" % [test.guid, original_display_name, test.display_name]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes deleted tests between discover cycles.[br] +## [br] +## A test is considered deleted when:[br] +## - It exists in the cache[br] +## - But is not found in the newly discovered tests[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_deleted_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + # lookup in cache + for test in cache: + if not discovered_tests.any(test_equals.bind(test)): + GdUnitSignals.instance().gdunit_test_discover_deleted.emit(test) + if _is_debug: + prints("-> deleted test id:%s %s:%d" % [test.guid, test.display_name, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("deleted_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes newly added tests between discover cycles.[br] +## [br] +## A test is considered added when:[br] +## - It exists in the newly discovered tests[br] +## - But is not found in the cache[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests[br] +## [param discover_sink] Callback to handle newly discovered tests +func sync_added_tests(source_file: String, discovered_tests: Array[GdUnitTestCase], discover_sink: Callable) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + # lookup in cache + for test in discovered_tests: + if not cache.any(test_equals.bind(test)): + discover_sink.call(test) + if _is_debug: + prints("-> added test id:%s %s:%d" % [test.guid, test.display_name, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("added_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +func is_test_renamed(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.line_number == right.line_number and left.test_name != right.test_name + + +func is_test_moved(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.line_number != right.line_number and left.test_name == right.test_name + + +func test_equals(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.display_name == right.display_name + + +# do rebuild the entire project, there is actual no way to enforce the Godot engine itself to do this +func rebuild_project(script: Script) -> void: + var class_path := ProjectSettings.globalize_path(script.resource_path) + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard: CSharpScript change detected on: '%s' [/color]" % class_path) + var scene_tree := Engine.get_main_loop() as SceneTree + await scene_tree.process_frame + + var output := [] + var exit_code := OS.execute("dotnet", ["--version"], output) + if exit_code == -1: + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Rebuild the project failed.[/color]") + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Can't find installed `dotnet`! Please check your environment is setup correctly.[/color]") + return + + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Found dotnet v%s[/color]" % str(output[0]).strip_edges()) + output.clear() + + exit_code = OS.execute("dotnet", ["build"], output) + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Rebuild the project ... [/color]") + for out: String in output: + print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges()) + await scene_tree.process_frame diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid new file mode 100644 index 0000000..0d8b757 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid @@ -0,0 +1 @@ +uid://dnyw8fm7vfc6i diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd new file mode 100644 index 0000000..5d0e5b6 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd @@ -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) diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid new file mode 100644 index 0000000..841f240 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid @@ -0,0 +1 @@ +uid://jf2a2cjlqkkr diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd new file mode 100644 index 0000000..cb3e407 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd @@ -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://" diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid new file mode 100644 index 0000000..c505a79 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid @@ -0,0 +1 @@ +uid://dr8h7h5duuybc diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd b/addons/gdUnit4/src/core/event/GdUnitEvent.gd new file mode 100644 index 0000000..fb5a3a6 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd @@ -0,0 +1,207 @@ +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, name: String, p_statistics: Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent: + _event_type = TESTCASE_AFTER + _guid = p_guid + _test_name = name + _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) or orphan_nodes() > 0 + + +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 diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid new file mode 100644 index 0000000..b01c4d3 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid @@ -0,0 +1 @@ +uid://be80qqix7cxt8 diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd new file mode 100644 index 0000000..774e7d4 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd @@ -0,0 +1,6 @@ +class_name GdUnitInit +extends GdUnitEvent + + +func _init() -> void: + _event_type = INIT diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid new file mode 100644 index 0000000..a18f4e5 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid @@ -0,0 +1 @@ +uid://cn0cbj8cbhp32 diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd new file mode 100644 index 0000000..d7a3c11 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd @@ -0,0 +1,6 @@ +class_name GdUnitStop +extends GdUnitEvent + + +func _init() -> void: + _event_type = STOP diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid new file mode 100644 index 0000000..7e32a4d --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid @@ -0,0 +1 @@ +uid://v7dkld783gk8 diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd new file mode 100644 index 0000000..c6194ef --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd @@ -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 diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid new file mode 100644 index 0000000..3d26683 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid @@ -0,0 +1 @@ +uid://cw044tcedgm0a diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd new file mode 100644 index 0000000..c7dd36f --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd @@ -0,0 +1,6 @@ +class_name GdUnitEventTestDiscoverStart +extends GdUnitEvent + + +func _init() -> void: + _event_type = DISCOVER_START diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid new file mode 100644 index 0000000..5478eb1 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid @@ -0,0 +1 @@ +uid://dfktvv2e63sor diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd new file mode 100644 index 0000000..52dab3f --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd @@ -0,0 +1,6 @@ +class_name GdUnitSessionClose +extends GdUnitEvent + + +func _init() -> void: + _event_type = SESSION_CLOSE diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid new file mode 100644 index 0000000..cdfdae2 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid @@ -0,0 +1 @@ +uid://bj0o8343e00i0 diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd new file mode 100644 index 0000000..420ad53 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd @@ -0,0 +1,6 @@ +class_name GdUnitSessionStart +extends GdUnitEvent + + +func _init() -> void: + _event_type = SESSION_START diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid new file mode 100644 index 0000000..8dc2be5 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid @@ -0,0 +1 @@ +uid://ctm8r6mjxxo18 diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd new file mode 100644 index 0000000..844561b --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd @@ -0,0 +1,274 @@ +## 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 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) + + if parent_context != null: + parent_context._orphan_monitor.add_child_monitor(_orphan_monitor) + orphan_monitor_start() + + _memory_observer = GdUnitMemoryObserver.new() + _report_collector = GdUnitTestReportCollector.new() + if parent_context != null: + parent_context._sub_context.append(self) + + +func dispose() -> void: + if test_suite != null: + test_suite.free() + _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() + + +func terminate() -> void: + if test_case: + test_case.do_terminate() + + +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_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext: + assert(p_test_case, "test_case is null") + var context := GdUnitExecutionContext.new(p_test_case.test_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: + error_monitor.stop() + 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_collect() -> void: + _orphan_monitor.collect() + + +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 orphan_count := GdUnitTestReportCollector.count_orphans(reports_) + var is_failed := !is_success() + 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 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() + + match(gc_orphan_check): + GC_ORPHANS_CHECK.SUITE_HOOK_AFTER: + _orphan_monitor.collect() + var orphan_infos := _orphan_monitor.detected_orphans() + if orphan_infos.is_empty(): + return + reports().push_front(GdUnitReport.new() \ + .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphan_infos)) + .with_current_value(orphan_infos.size())) + + GC_ORPHANS_CHECK.TEST_HOOK_AFTER: + _orphan_monitor.collect() + var orphans := _orphan_monitor.detected_orphans() + if not orphans.is_empty(): + reports().push_front(GdUnitReport.new()\ + .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans)) + .with_current_value(orphans.size())) + + GC_ORPHANS_CHECK.TEST_CASE: + var orphans := _orphan_monitor.detected_orphans() + if orphans.is_empty(): + var orphans_count := _orphan_monitor.orphans_count() + if orphans_count > 0: + reports().push_front(GdUnitReport.new() \ + .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_warning(orphans_count)) + .with_current_value(orphans_count)) + else: + reports().push_front(GdUnitReport.new()\ + .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans)) + .with_current_value(orphans.size())) diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid new file mode 100644 index 0000000..98ede87 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid @@ -0,0 +1 @@ +uid://doxksl3xmwd4t diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd new file mode 100644 index 0000000..dd03a31 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd @@ -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) diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid new file mode 100644 index 0000000..35465f8 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid @@ -0,0 +1 @@ +uid://3kyvv6qv76xy diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd new file mode 100644 index 0000000..91247bd --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd @@ -0,0 +1,77 @@ +# 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 __filter_is_orphan(report: GdUnitReport) -> bool: + return report.is_orphan() + + +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() + + +static func count_orphans(reports_: Array[GdUnitReport]) -> int: + var orphan_reports := reports_.filter(__filter_is_orphan) + if orphan_reports.is_empty(): + return 0 + ## Collect orphan count from the reports + var orphans := 0 + for report: GdUnitReport in orphan_reports: + orphans += report._current_value + return orphans + + +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) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid new file mode 100644 index 0000000..3c1601f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid @@ -0,0 +1 @@ +uid://5dmb52xvknlt diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd new file mode 100644 index 0000000..dfb4441 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd @@ -0,0 +1,56 @@ +## 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 +var _terminated := false + +func _init(debug_mode: bool = false) -> void: + _executeStage.set_debug_mode(debug_mode) + _debug_mode = debug_mode + GdUnitSignals.instance().gdunit_test_session_terminate.connect(_on_testsession_terminated) + + +func _on_testsession_terminated() -> void: + _terminated = true + GdUnitThreadManager.interrupt() + + +func run_and_wait(tests: Array[GdUnitTestCase]) -> void: + if !_debug_mode: + GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new()) + + var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans() + if not orphan_detection_enabled: + prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.") + + # 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(): + if _terminated: + break + @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 context := GdUnitExecutionContext.new(suite_path) + var test_suite := scanner.load_suite(script as GDScript, suite_tests) + context.test_suite = test_suite + (Engine.get_main_loop() as SceneTree).root.add_child(test_suite) + await _executeStage.execute(context) + context.dispose() + 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) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid new file mode 100644 index 0000000..6717eba --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid @@ -0,0 +1 @@ +uid://cfluc4qh1cia6 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd new file mode 100644 index 0000000..f106f21 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd @@ -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) + context.error_monitor_stop() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid new file mode 100644 index 0000000..f241c9f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid @@ -0,0 +1 @@ +uid://cry2ikys4ww2x diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd new file mode 100644 index 0000000..d7790a9 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd @@ -0,0 +1,20 @@ +## 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() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid new file mode 100644 index 0000000..2073bc3 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://lqofqnr401s3 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd new file mode 100644 index 0000000..473f1e2 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd @@ -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() + 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) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid new file mode 100644 index 0000000..7105242 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://bnx6cbvnp5pr1 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd new file mode 100644 index 0000000..03bbd0f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd @@ -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) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid new file mode 100644 index 0000000..1b19c2a --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid @@ -0,0 +1 @@ +uid://mwtvw184h4nk diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd new file mode 100644 index 0000000..a6ed8c5 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd @@ -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() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid new file mode 100644 index 0000000..2fdfdc3 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://fx62snxk32g4 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd new file mode 100644 index 0000000..07e3779 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd @@ -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()) + var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case) + await _stage_test.execute(test_case_context) + # stop on first error or if fail fast is enabled + if test_case.is_terminated() or (_fail_fast and not test_case_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 + + +# 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(), test_case.test_name(), 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 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid new file mode 100644 index 0000000..f250acb --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://c00afkws3ewfx diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd new file mode 100644 index 0000000..a04f7e7 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd @@ -0,0 +1,37 @@ +## 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. +@abstract 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. +@abstract func _execute(context: GdUnitExecutionContext) -> void diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid new file mode 100644 index 0000000..5ae6f69 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://bfvoxsa3oad2k diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd new file mode 100644 index 0000000..c1e0924 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd @@ -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(), context.test_case.test_name(), 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(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid new file mode 100644 index 0000000..7444e55 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://buqq7wmo7x6de diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd new file mode 100644 index 0000000..62dda37 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd @@ -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 diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid new file mode 100644 index 0000000..5df0ddb --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid @@ -0,0 +1 @@ +uid://b3tpfwvmfjkoq diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd new file mode 100644 index 0000000..03f8fa5 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd @@ -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(), context.test_case.test_name(), 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(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid new file mode 100644 index 0000000..9a0459d --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://ctjx74gak008y diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd new file mode 100644 index 0000000..b8ace50 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd @@ -0,0 +1,11 @@ +## The single test case execution stage.[br] +class_name GdUnitTestCaseSingleTestStage +extends IGdUnitExecutionStage + + +## Executes a single test case 'test_<name>()'.[br] +## It executes synchronized following stages[br] +## -> test_case() [br] +func _execute(context: GdUnitExecutionContext) -> void: + await context.test_case.execute() + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE) diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid new file mode 100644 index 0000000..48f2c97 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid @@ -0,0 +1 @@ +uid://66t5ejuybo1s diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd new file mode 100644 index 0000000..0f87ad1 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd @@ -0,0 +1,78 @@ +class_name GdUnitBaseReporterTestSessionHook +extends GdUnitTestSessionHook + + +var test_session: GdUnitTestSession: + get: + return test_session + set(value): + # disconnect first possible connected listener + if test_session != null: + test_session.test_event.disconnect(_on_test_event) + # add listening to current session + test_session = value + if test_session != null: + test_session.test_event.connect(_on_test_event) + + +var _report_summary: GdUnitReportSummary +var _reporter: GdUnitTestReporter +var _report_writer: GdUnitReportWriter +var _report_converter: Callable + +func _init(report_writer: GdUnitReportWriter, hook_name: String, hook_description: String, report_converter: Callable) -> void: + super(hook_name, hook_description) + _reporter = GdUnitTestReporter.new() + _report_writer = report_writer + _report_converter = report_converter + + +func startup(session: GdUnitTestSession) -> GdUnitResult: + test_session = session + _report_summary = GdUnitReportSummary.new(_report_converter) + _reporter.init_summary() + + return GdUnitResult.success() + + +func shutdown(session: GdUnitTestSession) -> GdUnitResult: + var report_path := _report_writer.write(session.report_path, _report_summary) + session.send_message("Open {0} Report at: file://{1}".format([_report_writer.output_format(), report_path])) + + return GdUnitResult.success() + + +func _on_test_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.TESTSUITE_BEFORE: + _reporter.init_statistics() + _report_summary.add_testsuite_report(event.resource_path(), event.suite_name(), event.total_count()) + GdUnitEvent.TESTSUITE_AFTER: + var statistics := _reporter.build_test_suite_statisitcs(event) + _report_summary.update_testsuite_counters( + event.resource_path(), + _reporter.error_count(statistics), + _reporter.failed_count(statistics), + _reporter.orphan_nodes(statistics), + _reporter.skipped_count(statistics), + _reporter.flaky_count(statistics), + event.elapsed_time()) + _report_summary.add_testsuite_reports( + event.resource_path(), + event.reports() + ) + GdUnitEvent.TESTCASE_BEFORE: + var test := test_session.find_test_by_id(event.guid()) + _report_summary.add_testcase(test.source_file, test.suite_name, test.display_name) + GdUnitEvent.TESTCASE_AFTER: + _reporter.add_test_statistics(event) + var test := test_session.find_test_by_id(event.guid()) + _report_summary.set_counters(test.source_file, + test.display_name, + event.error_count(), + event.failed_count(), + event.orphan_nodes(), + event.is_skipped(), + event.is_flaky(), + event.elapsed_time()) + _report_summary.add_reports(test.source_file, test.display_name, event.reports()) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..94a488c --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://dba8xduvhy4hd diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd new file mode 100644 index 0000000..4b8f390 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd @@ -0,0 +1,9 @@ +class_name GdUnitHtmlReporterTestSessionHook +extends GdUnitBaseReporterTestSessionHook + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func _init() -> void: + super(GdUnitHtmlReportWriter.new(), "GdUnitHtmlTestReporter", "The Html test reporting hook.", GdUnitTools.richtext_normalize) + set_meta("SYSTEM_HOOK", true) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..6af94d5 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://dsvmfyc8bxff8 diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd new file mode 100644 index 0000000..23850e4 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd @@ -0,0 +1,111 @@ +## @since GdUnit4 5.1.0 +## +## Base class for creating custom test session hooks in GdUnit4.[br] +## [br] +## [i]Test session hooks allow users to extend the GdUnit4 test framework by providing +## custom functionality that runs at specific points during the test execution lifecycle. +## This base class defines the interface that all test session hooks must implement.[/i] +## [br] +## [br] +## [b][u]Usage[/u][/b][br] +## 1. Create a new class that extends GdUnitTestSessionHook[br] +## 2. Override the required methods (startup, shutdown)[br] +## 3. Register your hook with the test engine (using the GdUnit4 settings dialog)[br] +## [br] +## [b][u]Example[/u][/b] +## [codeblock] +## class_name MyCustomTestHook +## extends GdUnitTestSessionHook +## +## func _init(): +## super("MyHook", "This is a description") +## +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Custom hook initialized") +## # Initialize resources, setup test environment, etc. +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Custom hook cleanup completed") +## # Cleanup resources, generate reports, etc. +## return GdUnitResult.success() +## [/codeblock] +## +## [b][u]Hook Lifecycle[/u][/b][br] +## 1. [i][b]Registration[/b][/i]: Hooks are registered with the test engine via settings dialog[br] +## 2. [i][b]Priority Sorting[/b][/i]: Hooks are sorted by priority[br] +## 3. [i][b]Startup[/b][/i]: startup() is called before test execution begins, if it returns an error is shown in the console[br] +## 4. [i][b]Test Execution[/b][/i]: Tests run normally (only if all hooks started successfully)[br] +## 5. [i][b]Shutdown[/b][/i]: shutdown() is called after all tests complete, regardless of startup success[br] +## [br] +## [b][u]Priority System[/u][/b][br] +## The priority system allows controlling the execution order of multiple hooks.[br] +## - The order can be changed in the GdUnit4 settings dialog.[br] +## - The priority of system hooks cannot be changed and they cannot be deleted.[br] +## [br] +## [b][u]Session Access[/u][/b][br] +## +## Both [i]startup()[/i] and [i]shutdown()[/i] methods receive a [GdUnitTestSession] parameter that provides:[br] +## - Access to test cases being executed[br] +## - Event emission capabilities for test progress tracking[br] +## - Message sending functionality for logging and communication[br] +class_name GdUnitTestSessionHook +extends RefCounted + + +## The display name of this hook. +var name: String: + get: + return name + + +## A detailed description of what this hook does. +var description: String: + get: + return description + + +## Initializes a new test session hook. +## +## [param _name] The display name for this hook +## [param _description] A detailed description of the hook's functionality +func _init(_name: String, _description: String) -> void: + self.name = _name + self.description = _description + + +## Called when the test session starts up, before any tests are executed.[br] +## [br] +## [color=yellow][i]This method should be overridden to implement custom initialization logic[/i][/color][br] +## [br] +## such as:[br] +## - Setting up test databases or external services[br] +## - Initializing mock objects or test fixtures[br] +## - Configuring logging or reporting systems[br] +## - Preparing the test environment[br] +## - Subscribing to test events via the session[br] +## [br] +## [param session] The test session instance providing access to test data and communication[br] +## [b]return:[/b] [code]GdUnitResult.success()[/code] if initialization succeeds, or [code]GdUnitResult.error("error")[/code] with +## an error message if initialization fails. +func startup(_session: GdUnitTestSession) -> GdUnitResult: + return GdUnitResult.error("%s:startup is not implemented" % get_script().resource_path) + + +## Called when the test session shuts down, after all tests have completed.[br] +## [br] +## [color=yellow][i]This method should be overridden to implement custom cleanup logic[/i][/color][br] +## [br] +## such as:[br] +## - Cleaning up test databases or external services[br] +## - Generating test reports or artifacts[br] +## - Releasing resources allocated during startup[br] +## - Performing final validation or assertions[br] +## - Processing collected test events and data[br] +## [br] +## [param session] The test session instance providing access to test results and communication[br] +## [b]return:[/b] [code]GdUnitResult.success()[/code] if cleanup succeeds, or [code]GdUnitResult.error("error")[/code] with +## an error message if cleanup fails. Cleanup errors are typically logged +## but don't prevent the test engine from shutting down. +func shutdown(_session: GdUnitTestSession) -> GdUnitResult: + return GdUnitResult.error("%s:shutdown is not implemented" % get_script().resource_path) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid new file mode 100644 index 0000000..719a67e --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://cd4t5sc3jv2i7 diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd new file mode 100644 index 0000000..5a9bdcb --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd @@ -0,0 +1,191 @@ +class_name GdUnitTestSessionHookService +extends Object + + +var enigne_hooks: Array[GdUnitTestSessionHook] = []: + get: + return enigne_hooks + set(value): + enigne_hooks.append(value) + + +var _save_settings: bool = false + + +static func instance() -> GdUnitTestSessionHookService: + return GdUnitSingleton.instance("GdUnitTestSessionHookService", func()->GdUnitTestSessionHookService: + GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session system hooks.") + var service := GdUnitTestSessionHookService.new() + # Register default system hooks here + service._save_settings = false + service.register(GdUnitHtmlReporterTestSessionHook.new()) + service.register(GdUnitXMLReporterTestSessionHook.new()) + service.load_hook_settings() + service._save_settings = true + return service + ) + + +static func contains_hook(current: GdUnitTestSessionHook, other: GdUnitTestSessionHook) -> bool: + return current.get_script().resource_path == other.get_script().resource_path + + +func find_custom(hook: GdUnitTestSessionHook) -> int: + for index in enigne_hooks.size(): + if contains_hook.call(enigne_hooks[index], hook): + return index + return -1 + + +func load_hook(hook_resourc_path: String) -> GdUnitResult: + if !FileAccess.file_exists(hook_resourc_path): + return GdUnitResult.error("The hook '%s' not exists." % hook_resourc_path) + var script: GDScript = load(hook_resourc_path) + if script.get_base_script() != GdUnitTestSessionHook: + return GdUnitResult.error("The hook '%s' must inhertit from 'GdUnitTestSessionHook'." % hook_resourc_path) + + return GdUnitResult.success(script.new()) + + +func enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void: + _enable_hook(hook, enabled) + GdUnitSignals.instance().gdunit_message.emit("Session hook '{name}' {enabled}.".format({ + "name": hook.name, + "enabled": "enabled" if enabled else "disabled"}) + ) + save_hock_setttings() + + +func register(hook: GdUnitTestSessionHook, enabled: bool = true) -> GdUnitResult: + if find_custom(hook) != -1: + return GdUnitResult.error("A hook instance of '%s' is already registered." % hook.get_script().resource_path) + + _enable_hook(hook, enabled) + enigne_hooks.append(hook) + save_hock_setttings() + GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' installed." % hook.name) + + return GdUnitResult.success() + + +func unregister(hook: GdUnitTestSessionHook) -> GdUnitResult: + var hook_index := find_custom(hook) + if hook_index == -1: + return GdUnitResult.error("The hook instance of '%s' is NOT registered." % hook.get_script().resource_path) + + enigne_hooks.remove_at(hook_index) + save_hock_setttings() + return GdUnitResult.success() + + +func move_before(hook: GdUnitTestSessionHook, before: GdUnitTestSessionHook) -> void: + var before_index := find_custom(before) + var hook_index := find_custom(hook) + + # Verify the hook to move is behind the hook to be moved + if before_index >= hook_index: + return + + enigne_hooks.remove_at(hook_index) + enigne_hooks.insert(before_index, hook) + save_hock_setttings() + + +func move_after(hook: GdUnitTestSessionHook, after: GdUnitTestSessionHook) -> void: + var after_index := find_custom(after) + var hook_index := find_custom(hook) + + # Verify the hook to move is before the hook to be moved + if after_index <= hook_index: + return + + enigne_hooks.remove_at(hook_index) + enigne_hooks.insert(after_index, hook) + save_hock_setttings() + + +func execute_startup(session: GdUnitTestSession) -> GdUnitResult: + return await execute("startup", session) + + +func execute_shutdown(session: GdUnitTestSession) -> GdUnitResult: + return await execute("shutdown", session, true) + + +func execute(hook_func: String, session: GdUnitTestSession, reverse := false) -> GdUnitResult: + var failed_hook_calls: Array[GdUnitResult] = [] + + for hook_index in enigne_hooks.size(): + var index := enigne_hooks.size()-hook_index-1 if reverse else hook_index + var hook: = enigne_hooks[index] + if not is_enabled(hook): + continue + if OS.is_stdout_verbose(): + GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' > %s()" % [hook.name, hook_func]) + var result: GdUnitResult = await hook.call(hook_func, session) + if result == null: + failed_hook_calls.push_back(GdUnitResult.error("Result is null! Check '%s'" % hook.get_script().resource_path)) + elif result.is_error(): + failed_hook_calls.push_back(result) + + if failed_hook_calls.is_empty(): + return GdUnitResult.success() + + var errors := failed_hook_calls.map(func(result: GdUnitResult) -> String: + return "Hook call '%s' failed with error: '%s'" % [hook_func, result.error_message()] + ) + return GdUnitResult.error( "\n".join(errors)) + + +func save_hock_setttings() -> void: + if not _save_settings: + return + + var hooks_to_save: Dictionary[String, bool] = {} + for hook in enigne_hooks: + var enabled: bool = hook.get_meta("enabled") + hooks_to_save[hook.get_script().resource_path] = enabled + + GdUnitSettings.set_session_hooks(hooks_to_save) + + +func load_hook_settings() -> void: + var hooks_resource_paths := GdUnitSettings.get_session_hooks() + if hooks_resource_paths.is_empty(): + return + + for hock_path: String in hooks_resource_paths.keys(): + var enabled := hooks_resource_paths[hock_path] + + # Do not reinstall already installed hooks + var existing_hook: GdUnitTestSessionHook = enigne_hooks.filter(func(element: GdUnitTestSessionHook) -> bool: + return element.get_script().resource_path == hock_path + ).front() + # Applay enabled settings + if existing_hook != null: + _enable_hook(existing_hook, enabled) + continue + + # Load additional hooks + var result := load_hook(hock_path) + if result.is_error(): + push_error(result.error_message()) + continue + + GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session hooks.") + var hook: GdUnitTestSessionHook = result.value() + + result = register(hook, enabled) + if result.is_error(): + push_error(result.error_message()) + continue + + +static func is_enabled(hook: GdUnitTestSessionHook) -> bool: + if hook.has_meta("enabled"): + return hook.get_meta("enabled") + return true + + +func _enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void: + hook.set_meta("enabled", enabled) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid new file mode 100644 index 0000000..690538d --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid @@ -0,0 +1 @@ +uid://bp5t2wb0depb6 diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd new file mode 100644 index 0000000..94caef5 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd @@ -0,0 +1,11 @@ +class_name GdUnitXMLReporterTestSessionHook +extends GdUnitBaseReporterTestSessionHook + + +func _init() -> void: + super(JUnitXmlReportWriter.new(), "GdUnitXMLTestReporter", "The JUnit XML test reporting hook.", convert_report_message) + set_meta("SYSTEM_HOOK", true) + + +func convert_report_message(value: String) -> String: + return value diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..42d6bff --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://btuob2k5s80l diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd new file mode 100644 index 0000000..fc83742 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd @@ -0,0 +1,25 @@ +class_name GdClassDescriptor +extends RefCounted + + +var _name :String +var _is_inner_class :bool +var _functions :Array[GdFunctionDescriptor] + + +func _init(p_name :String, p_is_inner_class :bool, p_functions :Array[GdFunctionDescriptor]) -> void: + _name = p_name + _is_inner_class = p_is_inner_class + _functions = p_functions + + +func name() -> String: + return _name + + +func is_inner_class() -> bool: + return _is_inner_class + + +func functions() -> Array[GdFunctionDescriptor]: + return _functions diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid new file mode 100644 index 0000000..759729e --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid @@ -0,0 +1 @@ +uid://baic1omvtg71r diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd new file mode 100644 index 0000000..f1b2244 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd @@ -0,0 +1,290 @@ +# holds all decodings for default values +class_name GdDefaultValueDecoder +extends GdUnitSingleton + + +@warning_ignore("unused_parameter") +var _decoders := { + TYPE_NIL: func(value :Variant) -> String: return "null", + TYPE_STRING: func(value :Variant) -> String: return '"%s"' % value, + TYPE_STRING_NAME: _on_type_StringName, + TYPE_BOOL: func(value :Variant) -> String: return str(value).to_lower(), + TYPE_FLOAT: func(value :Variant) -> String: return '%f' % value, + TYPE_COLOR: _on_type_Color, + TYPE_ARRAY: _on_type_Array.bind(TYPE_ARRAY), + TYPE_PACKED_BYTE_ARRAY: _on_type_Array.bind(TYPE_PACKED_BYTE_ARRAY), + TYPE_PACKED_STRING_ARRAY: _on_type_Array.bind(TYPE_PACKED_STRING_ARRAY), + TYPE_PACKED_FLOAT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT32_ARRAY), + TYPE_PACKED_FLOAT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT64_ARRAY), + TYPE_PACKED_INT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT32_ARRAY), + TYPE_PACKED_INT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT64_ARRAY), + TYPE_PACKED_COLOR_ARRAY: _on_type_Array.bind(TYPE_PACKED_COLOR_ARRAY), + TYPE_PACKED_VECTOR2_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR2_ARRAY), + TYPE_PACKED_VECTOR3_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR3_ARRAY), + TYPE_PACKED_VECTOR4_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR4_ARRAY), + TYPE_DICTIONARY: _on_type_Dictionary, + TYPE_RID: _on_type_RID, + TYPE_NODE_PATH: _on_type_NodePath, + TYPE_VECTOR2: _on_type_Vector.bind(TYPE_VECTOR2), + TYPE_VECTOR2I: _on_type_Vector.bind(TYPE_VECTOR2I), + TYPE_VECTOR3: _on_type_Vector.bind(TYPE_VECTOR3), + TYPE_VECTOR3I: _on_type_Vector.bind(TYPE_VECTOR3I), + TYPE_VECTOR4: _on_type_Vector.bind(TYPE_VECTOR4), + TYPE_VECTOR4I: _on_type_Vector.bind(TYPE_VECTOR4I), + TYPE_RECT2: _on_type_Rect2, + TYPE_RECT2I: _on_type_Rect2i, + TYPE_PLANE: _on_type_Plane, + TYPE_QUATERNION: _on_type_Quaternion, + TYPE_AABB: _on_type_AABB, + TYPE_BASIS: _on_type_Basis, + TYPE_CALLABLE: _on_type_Callable, + TYPE_SIGNAL: _on_type_Signal, + TYPE_TRANSFORM2D: _on_type_Transform2D, + TYPE_TRANSFORM3D: _on_type_Transform3D, + TYPE_PROJECTION: _on_type_Projection, + TYPE_OBJECT: _on_type_Object +} + +static func _regex(pattern: String) -> RegEx: + var regex := RegEx.new() + var err := regex.compile(pattern) + if err != OK: + push_error("error '%s' checked pattern '%s'" % [err, pattern]) + return null + return regex + + +func get_decoder(type: int) -> Callable: + return _decoders.get(type, func(value :Variant) -> String: return '%s' % value) + + +func _on_type_StringName(value: StringName) -> String: + if value.is_empty(): + return 'StringName()' + return 'StringName("%s")' % value + + +func _on_type_Object(value: Variant, _type: int) -> String: + return str(value) + + +func _on_type_Color(color: Color) -> String: + if color == Color.BLACK: + return "Color()" + return "Color%s" % color + + +func _on_type_NodePath(path: NodePath) -> String: + if path.is_empty(): + return 'NodePath()' + return 'NodePath("%s")' % path + + +func _on_type_Callable(_cb: Callable) -> String: + return 'Callable()' + + +func _on_type_Signal(_s: Signal) -> String: + return 'Signal()' + + +func _on_type_Dictionary(dict: Dictionary) -> String: + if dict.is_empty(): + return '{}' + return str(dict) + + +func _on_type_Array(value: Variant, type: int) -> String: + match type: + TYPE_ARRAY: + return str(value) + + TYPE_PACKED_COLOR_ARRAY: + var colors := PackedStringArray() + for color: Color in value: + @warning_ignore("return_value_discarded") + colors.append(_on_type_Color(color)) + if colors.is_empty(): + return "PackedColorArray()" + return "PackedColorArray([%s])" % ", ".join(colors) + + TYPE_PACKED_VECTOR2_ARRAY: + var vectors := PackedStringArray() + for vector: Vector2 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR2)) + if vectors.is_empty(): + return "PackedVector2Array()" + return "PackedVector2Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR3_ARRAY: + var vectors := PackedStringArray() + for vector: Vector3 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR3)) + if vectors.is_empty(): + return "PackedVector3Array()" + return "PackedVector3Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR4_ARRAY: + var vectors := PackedStringArray() + for vector: Vector4 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR4)) + if vectors.is_empty(): + return "PackedVector4Array()" + return "PackedVector4Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_STRING_ARRAY: + var values := PackedStringArray() + for v: String in value: + @warning_ignore("return_value_discarded") + values.append('"%s"' % v) + if values.is_empty(): + return "PackedStringArray()" + return "PackedStringArray([%s])" % ", ".join(values) + + TYPE_PACKED_BYTE_ARRAY,\ + TYPE_PACKED_FLOAT32_ARRAY,\ + TYPE_PACKED_FLOAT64_ARRAY,\ + TYPE_PACKED_INT32_ARRAY,\ + TYPE_PACKED_INT64_ARRAY: + var vectors := PackedStringArray() + for vector: Variant in value: + @warning_ignore("return_value_discarded") + vectors.append(str(vector)) + if vectors.is_empty(): + return GdObjects.type_as_string(type) + "()" + return "%s([%s])" % [GdObjects.type_as_string(type), ", ".join(vectors)] + return "unknown array type %d" % type + + +func _on_type_Vector(value: Variant, type: int) -> String: + + if typeof(value) != type: + push_error("Internal Error: type missmatch detected for value '%s', expects type %s" % [value, type_string(type)]) + return "" + + match type: + TYPE_VECTOR2: + if value == Vector2(): + return "Vector2()" + return "Vector2%s" % value + TYPE_VECTOR2I: + if value == Vector2i(): + return "Vector2i()" + return "Vector2i%s" % value + TYPE_VECTOR3: + if value == Vector3(): + return "Vector3()" + return "Vector3%s" % value + TYPE_VECTOR3I: + if value == Vector3i(): + return "Vector3i()" + return "Vector3i%s" % value + TYPE_VECTOR4: + if value == Vector4(): + return "Vector4()" + return "Vector4%s" % value + TYPE_VECTOR4I: + if value == Vector4i(): + return "Vector4i()" + return "Vector4i%s" % value + return "unknown vector type %d" % type + + +func _on_type_Transform2D(transform: Transform2D) -> String: + if transform == Transform2D(): + return "Transform2D()" + return "Transform2D(Vector2%s, Vector2%s, Vector2%s)" % [transform.x, transform.y, transform.origin] + + +func _on_type_Transform3D(transform: Transform3D) -> String: + if transform == Transform3D(): + return "Transform3D()" + return "Transform3D(Vector3%s, Vector3%s, Vector3%s, Vector3%s)" % [transform.basis.x, transform.basis.y, transform.basis.z, transform.origin] + + +func _on_type_Projection(projection: Projection) -> String: + return "Projection(Vector4%s, Vector4%s, Vector4%s, Vector4%s)" % [projection.x, projection.y, projection.z, projection.w] + + +@warning_ignore("unused_parameter") +func _on_type_RID(value: RID) -> String: + return "RID()" + + +func _on_type_Rect2(rect: Rect2) -> String: + if rect == Rect2(): + return "Rect2()" + return "Rect2(Vector2%s, Vector2%s)" % [rect.position, rect.size] + + +func _on_type_Rect2i(rect: Variant) -> String: + if rect == Rect2i(): + return "Rect2i()" + return "Rect2i(Vector2i%s, Vector2i%s)" % [rect.position, rect.size] + + +func _on_type_Plane(plane: Plane) -> String: + if plane == Plane(): + return "Plane()" + return "Plane(%d, %d, %d, %d)" % [plane.x, plane.y, plane.z, plane.d] + + +func _on_type_Quaternion(quaternion: Quaternion) -> String: + if quaternion == Quaternion(): + return "Quaternion()" + return "Quaternion(%d, %d, %d, %d)" % [quaternion.x, quaternion.y, quaternion.z, quaternion.w] + + +func _on_type_AABB(aabb: AABB) -> String: + if aabb == AABB(): + return "AABB()" + return "AABB(Vector3%s, Vector3%s)" % [aabb.position, aabb.size] + + +func _on_type_Basis(basis: Basis) -> String: + if basis == Basis(): + return "Basis()" + return "Basis(Vector3%s, Vector3%s, Vector3%s)" % [basis.x, basis.y, basis.z] + + +static func decode(value: Variant) -> String: + var type := typeof(value) + @warning_ignore("unsafe_cast") + if GdArrayTools.is_type_array(type) and (value as Array).is_empty(): + return "<empty>" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func decode_typed(type: int, value: Variant) -> String: + if value == null: + return "null" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func _get_value_decoder(type: int) -> Callable: + var decoder: GdDefaultValueDecoder = instance( + "GdUnitDefaultValueDecoders", + func() -> GdDefaultValueDecoder: + return GdDefaultValueDecoder.new()) + return decoder.get_decoder(type) diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid new file mode 100644 index 0000000..0f8e9c1 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid @@ -0,0 +1 @@ +uid://dl0wf0ieoppil diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd new file mode 100644 index 0000000..bd38eb2 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd @@ -0,0 +1,208 @@ +class_name GdFunctionArgument +extends RefCounted + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const UNDEFINED: String = "<-NO_ARG->" +const ARG_PARAMETERIZED_TEST := ["test_parameters", "_test_parameters"] + +static var _fuzzer_regex: RegEx +static var _cleanup_leading_spaces: RegEx +static var _fix_comma_space: RegEx + +var _name: String +var _type: int +var _type_hint: int +var _default_value: Variant +var _parameter_sets: PackedStringArray = [] + + +func _init(p_name: String, p_type: int, value: Variant = UNDEFINED, p_type_hint: int = TYPE_NIL) -> void: + _init_static_variables() + _name = p_name + _type = p_type + _type_hint = p_type_hint + if value != null and p_name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameter_set(str(value)) + _default_value = value + # is argument a fuzzer? + if _type == TYPE_OBJECT and _fuzzer_regex.search(_name): + _type = GdObjects.TYPE_FUZZER + + +func _init_static_variables() -> void: + if _fuzzer_regex == null: + _fuzzer_regex = GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)") + _cleanup_leading_spaces = RegEx.create_from_string("(?m)^[ \t]+") + _fix_comma_space = RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""") + + +func name() -> String: + return _name + + +func default() -> Variant: + return type_convert(_default_value, _type) + + +func set_value(value: String) -> void: + # we onle need to apply default values for Objects, all others are provided by the method descriptor + if _type == GdObjects.TYPE_FUZZER: + _default_value = value + return + if _name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameter_set(value) + _default_value = value + return + + if _type == TYPE_NIL or _type == GdObjects.TYPE_VARIANT: + _type = _extract_value_type(value) + if _type == GdObjects.TYPE_VARIANT and _default_value == null: + _default_value = value + if _default_value == null: + match _type: + TYPE_DICTIONARY: + _default_value = as_dictionary(value) + TYPE_ARRAY: + _default_value = as_array(value) + GdObjects.TYPE_FUZZER: + _default_value = value + _: + _default_value = str_to_var(value) + # if converting fails assign the original value without converting + if _default_value == null and value != null: + _default_value = value + #prints("set default_value: ", _default_value, "with type %d" % _type, " from original: '%s'" % value) + + +func _extract_value_type(value: String) -> int: + if value != UNDEFINED: + if _fuzzer_regex.search(_name): + return GdObjects.TYPE_FUZZER + if value.rfind(")") == value.length()-1: + return GdObjects.TYPE_FUNC + return _type + + +func value_as_string() -> String: + if has_default(): + return GdDefaultValueDecoder.decode_typed(_type, _default_value) + return "" + + +func plain_value() -> Variant: + return _default_value + + +func type() -> int: + return _type + + +func type_hint() -> int: + return _type_hint + + +func has_default() -> bool: + return not is_same(_default_value, UNDEFINED) + + +func is_typed_array() -> bool: + return _type == TYPE_ARRAY and _type_hint != TYPE_NIL + + +func is_parameter_set() -> bool: + return _name in ARG_PARAMETERIZED_TEST + + +func parameter_sets() -> PackedStringArray: + return _parameter_sets + + +static func get_parameter_set(parameters :Array[GdFunctionArgument]) -> GdFunctionArgument: + for current in parameters: + if current != null and current.is_parameter_set(): + return current + return null + + +func _to_string() -> String: + var s := _name + if _type != TYPE_NIL: + s += ": " + GdObjects.type_as_string(_type) + if _type_hint != TYPE_NIL: + s += "[%s]" % GdObjects.type_as_string(_type_hint) + if has_default(): + s += "=" + value_as_string() + return s + + +func _parse_parameter_set(input :String) -> PackedStringArray: + if not input.contains("["): + return [] + + input = _cleanup_leading_spaces.sub(input, "", true) + input = input.replace("\n", "").strip_edges().trim_prefix("[").trim_suffix("]").trim_prefix("]") + var single_quote := false + var double_quote := false + var array_end := 0 + var current_index := 0 + var output :PackedStringArray = [] + var buf := input.to_utf8_buffer() + var collected_characters: = PackedByteArray() + var matched :bool = false + + for c in buf: + current_index += 1 + matched = current_index == buf.size() + @warning_ignore("return_value_discarded") + collected_characters.push_back(c) + + match c: + # ' ': ignore spaces between array elements + 32: if array_end == 0 and (not double_quote and not single_quote): + collected_characters.remove_at(collected_characters.size()-1) + # ',': step over array element seperator ',' + 44: if array_end == 0: + matched = true + collected_characters.remove_at(collected_characters.size()-1) + # '`': + 39: single_quote = !single_quote + # '"': + 34: if not single_quote: double_quote = !double_quote + # '[' + 91: if not double_quote and not single_quote: array_end +=1 # counts array open + # ']' + 93: if not double_quote and not single_quote: array_end -=1 # counts array closed + + # if array closed than collect the element + if matched: + var parameters := _fix_comma_space.sub(collected_characters.get_string_from_utf8(), ", ", true) + if not parameters.is_empty(): + @warning_ignore("return_value_discarded") + output.append(parameters) + collected_characters.clear() + matched = false + return output + + +## value converters + +func as_array(value: String) -> Array: + if value == "Array()" or value == "[]": + return [] + + if value.begins_with("Array("): + value = value.lstrip("Array(").rstrip(")") + if value.begins_with("["): + return str_to_var(value) + return [] + + +func as_dictionary(value: String) -> Dictionary: + if value == "Dictionary()": + return {} + if value.begins_with("Dictionary("): + value = value.lstrip("Dictionary(").rstrip(")") + if value.begins_with("{"): + return str_to_var(value) + return {} diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid new file mode 100644 index 0000000..b98b975 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid @@ -0,0 +1 @@ +uid://dtwu3dpeq6huv diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd new file mode 100644 index 0000000..7eae4b2 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd @@ -0,0 +1,286 @@ +class_name GdFunctionDescriptor +extends RefCounted + +var _is_virtual :bool +var _is_static :bool +var _is_engine :bool +var _is_coroutine :bool +var _name :String +var _source_path: String +var _line_number :int +var _return_type :int +var _return_class :String +var _args : Array[GdFunctionArgument] +var _varargs :Array[GdFunctionArgument] + + + +static func create(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor: + var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, false, false, p_return_type, "", p_args) + fd.enrich_file_info(p_source_path, p_source_line) + return fd + +static func create_static(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor: + var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, true, false, p_return_type, "", p_args) + fd.enrich_file_info(p_source_path, p_source_line) + return fd + + +func _init(p_name :String, + p_line_number :int, + p_is_virtual :bool, + p_is_static :bool, + p_is_engine :bool, + p_return_type :int, + p_return_class :String, + p_args : Array[GdFunctionArgument], + p_varargs :Array[GdFunctionArgument] = []) -> void: + _name = p_name + _line_number = p_line_number + _return_type = p_return_type + _return_class = p_return_class + _is_virtual = p_is_virtual + _is_static = p_is_static + _is_engine = p_is_engine + _is_coroutine = false + _args = p_args + _varargs = p_varargs + + +func with_return_class(clazz_name: String) -> GdFunctionDescriptor: + _return_class = clazz_name + return self + + +func name() -> String: + return _name + + +func source_path() -> String: + return _source_path + + +func line_number() -> int: + return _line_number + + +func is_virtual() -> bool: + return _is_virtual + + +func is_static() -> bool: + return _is_static + + +func is_engine() -> bool: + return _is_engine + + +func is_vararg() -> bool: + return not _varargs.is_empty() + + +func is_coroutine() -> bool: + return _is_coroutine + + +func is_parameterized() -> bool: + for current in _args: + var arg :GdFunctionArgument = current + if arg.name() in GdFunctionArgument.ARG_PARAMETERIZED_TEST: + return true + return false + + +func is_private() -> bool: + return name().begins_with("_") and not is_virtual() + + +func return_type() -> int: + return _return_type + + +func return_type_as_string() -> String: + if return_type() == TYPE_NIL: + return "void" + if (return_type() == TYPE_OBJECT or return_type() == GdObjects.TYPE_ENUM) and not _return_class.is_empty(): + return _return_class + return GdObjects.type_as_string(return_type()) + + +func set_argument_value(arg_name: String, value: String) -> void: + var argument: GdFunctionArgument = _args.filter(func(arg: GdFunctionArgument) -> bool: + return arg.name() == arg_name + ).front() + if argument != null: + argument.set_value(value) + + +func enrich_arguments(arguments: Array[Dictionary]) -> void: + for arg_index: int in arguments.size(): + var arg: Dictionary = arguments[arg_index] + if arg["type"] != GdObjects.TYPE_VARARG: + var arg_name: String = arg["name"] + var arg_value: String = arg["value"] + set_argument_value(arg_name, arg_value) + + +func enrich_file_info(p_source_path: String, p_line_number: int) -> void: + _source_path = p_source_path + _line_number = p_line_number + + +func args() -> Array[GdFunctionArgument]: + return _args + + +func varargs() -> Array[GdFunctionArgument]: + return _varargs + + +func typed_args() -> String: + var collect := PackedStringArray() + for arg in args(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + for arg in varargs(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + return ", ".join(collect) + + +func _to_string() -> String: + var fsignature := "virtual " if is_virtual() else "" + if _return_type == TYPE_NIL: + return fsignature + "[Line:%s] func %s(%s):" % [line_number(), name(), typed_args()] + var func_template := fsignature + "[Line:%s] func %s(%s) -> %s:" + if is_static(): + func_template= "[Line:%s] static func %s(%s) -> %s:" + return func_template % [line_number(), name(), typed_args(), return_type_as_string()] + + +# extract function description given by Object.get_method_list() +static func extract_from(descriptor :Dictionary, is_engine_ := true) -> GdFunctionDescriptor: + var func_name: String = descriptor["name"] + var function_flags: int = descriptor["flags"] + var return_descriptor: Dictionary = descriptor["return"] + var clazz_name: String = return_descriptor["class_name"] + var is_virtual_: bool = function_flags & METHOD_FLAG_VIRTUAL + var is_static_: bool = function_flags & METHOD_FLAG_STATIC + var is_vararg_: bool = function_flags & METHOD_FLAG_VARARG + + return GdFunctionDescriptor.new( + func_name, + -1, + is_virtual_, + is_static_, + is_engine_, + _extract_return_type(return_descriptor), + clazz_name, + _extract_args(descriptor), + _build_varargs(is_vararg_) + ) + +# temporary exclude GlobalScope enums +const enum_fix := [ + "Side", + "Corner", + "Orientation", + "ClockDirection", + "HorizontalAlignment", + "VerticalAlignment", + "InlineAlignment", + "EulerOrder", + "Error", + "Key", + "MIDIMessage", + "MouseButton", + "MouseButtonMask", + "JoyButton", + "JoyAxis", + "PropertyHint", + "PropertyUsageFlags", + "MethodFlags", + "Variant.Type", + "Control.LayoutMode"] + + +static func _extract_return_type(return_info :Dictionary) -> int: + var type :int = return_info["type"] + var usage :int = return_info["usage"] + if type == TYPE_INT and usage & PROPERTY_USAGE_CLASS_IS_ENUM: + return GdObjects.TYPE_ENUM + if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + if type == TYPE_NIL and usage == 6: + return GdObjects.TYPE_VOID + return type + + +static func _extract_args(descriptor :Dictionary) -> Array[GdFunctionArgument]: + var args_ :Array[GdFunctionArgument] = [] + var arguments :Array = descriptor["args"] + var defaults :Array = descriptor["default_args"] + # iterate backwards because the default values are stored from right to left + while not arguments.is_empty(): + var arg :Dictionary = arguments.pop_back() + var arg_name := _argument_name(arg) + var arg_type := _argument_type(arg) + var arg_type_hint := _argument_hint(arg) + #var arg_class: StringName = arg["class_name"] + var default_value: Variant = GdFunctionArgument.UNDEFINED if defaults.is_empty() else defaults.pop_back() + args_.push_front(GdFunctionArgument.new(arg_name, arg_type, default_value, arg_type_hint)) + return args_ + + +static func _build_varargs(p_is_vararg :bool) -> Array[GdFunctionArgument]: + var varargs_ :Array[GdFunctionArgument] = [] + if not p_is_vararg: + return varargs_ + varargs_.push_back(GdFunctionArgument.new("varargs", GdObjects.TYPE_VARARG, '')) + return varargs_ + + +static func _argument_name(arg :Dictionary) -> String: + return arg["name"] + + +static func _argument_type(arg :Dictionary) -> int: + var type :int = arg["type"] + var usage :int = arg["usage"] + + if type == TYPE_OBJECT: + if arg["class_name"] == "Node": + return GdObjects.TYPE_NODE + if arg["class_name"] == "Fuzzer": + return GdObjects.TYPE_FUZZER + + # if the argument untyped we need to scan the assignef value type + if type == TYPE_NIL and usage == PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + return type + + +static func _argument_hint(arg :Dictionary) -> int: + var hint :int = arg["hint"] + var hint_string :String = arg["hint_string"] + + match hint: + PROPERTY_HINT_ARRAY_TYPE: + return GdObjects.string_to_type(hint_string) + _: + return 0 + + +static func _argument_type_as_string(arg :Dictionary) -> String: + var type := _argument_type(arg) + match type: + TYPE_NIL: + return "" + TYPE_OBJECT: + var clazz_name :String = arg["class_name"] + if not clazz_name.is_empty(): + return clazz_name + return "" + _: + return GdObjects.type_as_string(type) diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid new file mode 100644 index 0000000..0396b95 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid @@ -0,0 +1 @@ +uid://csn6e1csw3fry diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd new file mode 100644 index 0000000..9c45e62 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd @@ -0,0 +1,188 @@ +class_name GdFunctionParameterSetResolver +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ParameterExtractor extends '${clazz_path}' + +func __extract_test_parameters() -> Array: + return ${test_params} + +""" + +const EXCLUDE_PROPERTIES_TO_COPY = [ + "script", + "type", + "Node", + "_import_path"] + + +var _fd: GdFunctionDescriptor +var _static_sets_by_index := {} +var _is_static := true + +func _init(fd: GdFunctionDescriptor) -> void: + _fd = fd + + +func resolve_test_cases(script: GDScript) -> Array[GdUnitTestCase]: + if not is_parameterized(): + return [GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name())] + return extract_test_cases_by_reflection(script) + + +func is_parameterized() -> bool: + return _fd.is_parameterized() + + +func is_parameter_sets_static() -> bool: + return _is_static + + +func is_parameter_set_static(index: int) -> bool: + return _is_static and _static_sets_by_index.get(index, false) + + +# validates the given arguments are complete and matches to required input fields of the test function +func validate(input_value_set: Array) -> String: + var input_arguments := _fd.args() + # check given parameter set with test case arguments + var expected_arg_count := input_arguments.size() - 1 + for input_values :Variant in input_value_set: + var parameter_set_index := input_value_set.find(input_values) + if input_values is Array: + var arr_values: Array = input_values + var current_arg_count := arr_values.size() + if current_arg_count != expected_arg_count: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n The test case requires [%d] input parameters, but the set contains [%d]" % [parameter_set_index, expected_arg_count, current_arg_count] + var error := validate_parameter_types(input_arguments, arr_values, parameter_set_index) + if not error.is_empty(): + return error + else: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n Expecting an array of input values." % parameter_set_index + return "" + + +static func validate_parameter_types(input_arguments: Array, input_values: Array, parameter_set_index: int) -> String: + for i in input_arguments.size(): + var input_param: GdFunctionArgument = input_arguments[i] + # only check the test input arguments + if input_param.is_parameter_set(): + continue + var input_param_type := input_param.type() + var input_value :Variant = input_values[i] + var input_value_type := typeof(input_value) + # input parameter is not typed or is Variant we skip the type test + if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT: + continue + # is input type enum allow int values + if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT: + continue + # allow only equal types and object == null + if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL: + continue + if input_param_type != input_value_type: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n The value '%s' does not match the required input parameter <%s>." % [parameter_set_index, input_value, input_param] + return "" + + +func extract_test_cases_by_reflection(script: GDScript) -> Array[GdUnitTestCase]: + var source: Node = script.new() + source.queue_free() + + var fa := GdFunctionArgument.get_parameter_set(_fd.args()) + var parameter_sets := fa.parameter_sets() + # if no parameter set detected we need to resolve it by using reflection + if parameter_sets.size() == 0: + _is_static = false + return _extract_test_cases_by_reflection(source, script) + else: + var test_cases: Array[GdUnitTestCase] = [] + var property_names := _extract_property_names(source) + for parameter_set_index in parameter_sets.size(): + var parameter_set := parameter_sets[parameter_set_index] + _static_sets_by_index[parameter_set_index] = _is_static_parameter_set(parameter_set, property_names) + @warning_ignore("return_value_discarded") + test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), parameter_set_index, parameter_set)) + parameter_set_index += 1 + return test_cases + + +func _extract_property_names(source: Node) -> PackedStringArray: + return source.get_property_list()\ + .map(func(property :Dictionary) -> String: return property["name"])\ + .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property)) + + +# tests if the test property set contains an property reference by name, if not the parameter set holds only static values +func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool: + for property_name in property_names: + if parameters.contains(property_name): + _is_static = false + return false + return true + + +func _extract_test_cases_by_reflection(source: Node, script: GDScript) -> Array[GdUnitTestCase]: + var parameter_sets := load_parameter_sets(source) + var test_cases: Array[GdUnitTestCase] = [] + for index in parameter_sets.size(): + var parameter_set := str(parameter_sets[index]) + @warning_ignore("return_value_discarded") + test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), index, parameter_set)) + return test_cases + + +# extracts the arguments from the given test case, using kind of reflection solution +# to restore the parameters from a string representation to real instance type +func load_parameter_sets(source: Node) -> Array: + var source_script: GDScript = source.get_script() + var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args()) + var source_code := CLASS_TEMPLATE \ + .replace("${clazz_path}", source_script.resource_path) \ + .replace("${test_params}", parameter_arg.value_as_string()) + var script := GDScript.new() + script.source_code = source_code + # enable this lines only for debuging + #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name() + #DirAccess.remove_absolute(script.resource_path) + #ResourceSaver.save(script, script.resource_path) + var result := script.reload() + if result != OK: + push_error("Extracting test parameters failed! Script loading error: %s" % result) + return [] + var instance: Node = script.new() + GdFunctionParameterSetResolver.copy_properties(source, instance) + instance.queue_free() + var parameter_sets: Array = instance.call("__extract_test_parameters") + return fixure_typed_parameters(parameter_sets, _fd.args()) + + +func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array: + for parameter_set_index in parameter_sets.size(): + var parameter_set: Array = parameter_sets[parameter_set_index] + # run over all function arguments + for parameter_index in parameter_set.size(): + var parameter :Variant = parameter_set[parameter_index] + var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index] + if parameter is Array: + var as_array: Array = parameter + # we need to convert the untyped array to the expected typed version + if arg_descriptor.is_typed_array(): + parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null) + return parameter_sets + + +static func copy_properties(source: Object, dest: Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, "<empty>"); + continue + dest.set(property_name, property_value) diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid new file mode 100644 index 0000000..2d86843 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://ck3w7dlo43ky6 diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd new file mode 100644 index 0000000..a102508 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd @@ -0,0 +1,764 @@ +class_name GdScriptParser +extends RefCounted + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const TYPE_VOID = GdObjects.TYPE_VOID +const TYPE_VARIANT = GdObjects.TYPE_VARIANT +const TYPE_VARARG = GdObjects.TYPE_VARARG +const TYPE_FUNC = GdObjects.TYPE_FUNC +const TYPE_FUZZER = GdObjects.TYPE_FUZZER +const TYPE_ENUM = GdObjects.TYPE_ENUM + + +var TOKEN_NOT_MATCH := Token.new("") +var TOKEN_SPACE := SkippableToken.new(" ") +var TOKEN_TABULATOR := SkippableToken.new("\t") +var TOKEN_NEW_LINE := SkippableToken.new("\n") +var TOKEN_COMMENT := SkippableToken.new("#") +var TOKEN_CLASS_NAME := RegExToken.new("class_name", GdUnitTools.to_regex("(class_name)\\s+([\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class_name)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_INNER_CLASS := TokenInnerClass.new("class", GdUnitTools.to_regex("(class)\\s+(\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_EXTENDS := RegExToken.new("extends", GdUnitTools.to_regex("extends\\s+")) +var TOKEN_ENUM := RegExToken.new("enum", GdUnitTools.to_regex("enum\\s+")) +var TOKEN_FUNCTION_STATIC_DECLARATION := RegExToken.new("static func", GdUnitTools.to_regex("^static\\s+func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION_DECLARATION := RegExToken.new("func", GdUnitTools.to_regex("^func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION := Token.new(".") +var TOKEN_FUNCTION_RETURN_TYPE := Token.new("->") +var TOKEN_FUNCTION_END := Token.new("):") +var TOKEN_ARGUMENT_ASIGNMENT := Token.new("=") +var TOKEN_ARGUMENT_TYPE_ASIGNMENT := Token.new(":=") +var TOKEN_ARGUMENT_FUZZER := FuzzerToken.new(GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)")) +var TOKEN_ARGUMENT_TYPE := Token.new(":") +var TOKEN_ARGUMENT_VARIADIC := Token.new("...") +var TOKEN_ARGUMENT_SEPARATOR := Token.new(",") +var TOKEN_BRACKET_ROUND_OPEN := Token.new("(") +var TOKEN_BRACKET_ROUND_CLOSE := Token.new(")") +var TOKEN_BRACKET_SQUARE_OPEN := Token.new("[") +var TOKEN_BRACKET_SQUARE_CLOSE := Token.new("]") +var TOKEN_BRACKET_CURLY_OPEN := Token.new("{") +var TOKEN_BRACKET_CURLY_CLOSE := Token.new("}") + + +var OPERATOR_ADD := Operator.new("+") +var OPERATOR_SUB := Operator.new("-") +var OPERATOR_MUL := Operator.new("*") +var OPERATOR_DIV := Operator.new("/") +var OPERATOR_REMAINDER := Operator.new("%") + +var TOKENS :Array[Token] = [ + TOKEN_SPACE, + TOKEN_TABULATOR, + TOKEN_NEW_LINE, + TOKEN_COMMENT, + TOKEN_BRACKET_ROUND_OPEN, + TOKEN_BRACKET_ROUND_CLOSE, + TOKEN_BRACKET_SQUARE_OPEN, + TOKEN_BRACKET_SQUARE_CLOSE, + TOKEN_BRACKET_CURLY_OPEN, + TOKEN_BRACKET_CURLY_CLOSE, + TOKEN_CLASS_NAME, + TOKEN_INNER_CLASS, + TOKEN_EXTENDS, + TOKEN_ENUM, + TOKEN_FUNCTION_STATIC_DECLARATION, + TOKEN_FUNCTION_DECLARATION, + TOKEN_ARGUMENT_FUZZER, + TOKEN_ARGUMENT_TYPE_ASIGNMENT, + TOKEN_ARGUMENT_ASIGNMENT, + TOKEN_ARGUMENT_TYPE, + TOKEN_ARGUMENT_VARIADIC, + TOKEN_FUNCTION, + TOKEN_ARGUMENT_SEPARATOR, + TOKEN_FUNCTION_RETURN_TYPE, + OPERATOR_ADD, + OPERATOR_SUB, + OPERATOR_MUL, + OPERATOR_DIV, + OPERATOR_REMAINDER, +] + +var _regex_strip_comments := GdUnitTools.to_regex("^([^#\"']|'[^']*'|\"[^\"]*\")*\\K#.*") +var _scanned_inner_classes := PackedStringArray() +var _script_constants := {} +var _is_awaiting := GdUnitTools.to_regex("\\bawait\\s+(?![^\"]*\"[^\"]*$)(?!.*#.*await)") + + +static func to_unix_format(input :String) -> String: + return input.replace("\r\n", "\n") + + +class Token extends RefCounted: + var _token: String + var _consumed: int + var _is_operator: bool + + func _init(p_token: String, p_is_operator := false) -> void: + _token = p_token + _is_operator = p_is_operator + _consumed = p_token.length() + + func match(input: String, pos: int) -> bool: + return input.findn(_token, pos) == pos + + func value() -> Variant: + return _token + + func is_operator() -> bool: + return _is_operator + + func is_inner_class() -> bool: + return _token == "class" + + func is_variable() -> bool: + return false + + func is_token(token_name :String) -> bool: + return _token == token_name + + func is_skippable() -> bool: + return false + + func _to_string() -> String: + return "Token{" + _token + "}" + + +class Operator extends Token: + func _init(p_value: String) -> void: + super(p_value, true) + + func _to_string() -> String: + return "OperatorToken{%s}" % [_token] + + +# A skippable token, is just a placeholder like space or tabs +class SkippableToken extends Token: + + func _init(p_token: String) -> void: + super(p_token) + + func is_skippable() -> bool: + return true + + +# Token to parse function arguments +class Variable extends Token: + var _plain_value :String + var _typed_value :Variant + var _type :int = TYPE_NIL + + + func _init(p_value: String) -> void: + super(p_value) + _type = _scan_type(p_value) + _plain_value = p_value + _typed_value = _cast_to_type(p_value, _type) + + + func _scan_type(p_value: String) -> int: + if p_value.begins_with("\"") and p_value.ends_with("\""): + return TYPE_STRING + var type_ := GdObjects.string_to_type(p_value) + if type_ != TYPE_NIL: + return type_ + if p_value.is_valid_int(): + return TYPE_INT + if p_value.is_valid_float(): + return TYPE_FLOAT + if p_value.is_valid_hex_number(): + return TYPE_INT + return TYPE_OBJECT + + + func _cast_to_type(p_value :String, p_type: int) -> Variant: + match p_type: + TYPE_STRING: + return p_value#.substr(1, p_value.length() - 2) + TYPE_INT: + return p_value.to_int() + TYPE_FLOAT: + return p_value.to_float() + return p_value + + + func is_variable() -> bool: + return true + + + func type() -> int: + return _type + + + func value() -> Variant: + return _typed_value + + + func plain_value() -> String: + return _plain_value + + + func _to_string() -> String: + return "Variable{%s: %s : '%s'}" % [_plain_value, GdObjects.type_as_string(_type), _token] + + +class RegExToken extends Token: + var _regex: RegEx + var _extract_group_index: int + var _value := "" + + + func _init(token: String, regex: RegEx, extract_group_index: int = -1) -> void: + super(token, false) + _regex = regex + _extract_group_index = extract_group_index + + + func match(input: String, pos: int) -> bool: + var matching := _regex.search(input, pos) + if matching == null or pos != matching.get_start(): + return false + if _extract_group_index != -1: + _value = matching.get_string(_extract_group_index) + _consumed = matching.get_end() - matching.get_start() + return true + + + func value() -> String: + return _value + + +# Token to parse Fuzzers +class FuzzerToken extends RegExToken: + + + func _init(regex: RegEx) -> void: + super("fuzzer", regex, 1) + + + func name() -> String: + return value() + + + func type() -> int: + return GdObjects.TYPE_FUZZER + + + func _to_string() -> String: + return "FuzzerToken{%s: '%s'}" % [value(), _token] + + +class TokenInnerClass extends RegExToken: + var _content := PackedStringArray() + + + static func _strip_leading_spaces(input: String) -> String: + var characters := input.to_utf8_buffer() + while not characters.is_empty(): + if characters[0] != 0x20: + break + characters.remove_at(0) + return characters.get_string_from_utf8() + + + static func _consumed_bytes(row: String) -> int: + return row.replace(" ", "").replace(" ", "").length() + + + func _init(token: String, p_regex: RegEx, extract_group_index: int = -1) -> void: + super(token, p_regex, extract_group_index) + + + func is_class_name(clazz_name: String) -> bool: + return value() == clazz_name + + + func content() -> PackedStringArray: + return _content + + + @warning_ignore_start("return_value_discarded") + func parse(source_rows: PackedStringArray, offset: int) -> void: + # add class signature + _content.clear() + _content.append(source_rows[offset]) + # parse class content + for row_index in range(offset+1, source_rows.size()): + # scan until next non tab + var source_row := source_rows[row_index] + var row := TokenInnerClass._strip_leading_spaces(source_row) + if row.is_empty() or row.begins_with("\t") or row.begins_with("#"): + # fold all line to left by removing leading tabs and spaces + if source_row.begins_with("\t"): + source_row = source_row.trim_prefix("\t") + # refomat invalid empty lines + if source_row.dedent().is_empty(): + _content.append("") + else: + _content.append(source_row) + continue + break + _consumed += TokenInnerClass._consumed_bytes("".join(_content)) + @warning_ignore_restore("return_value_discarded") + + + func _to_string() -> String: + return "TokenInnerClass{%s}" % [value()] + + + +func get_token(input: String, current_index: int) -> Token: + for t in TOKENS: + if t.match(input, current_index): + return t + return TOKEN_NOT_MATCH + + +func next_token(input: String, current_index: int, ignore_tokens :Array[Token] = []) -> Token: + var token := TOKEN_NOT_MATCH + for t :Token in TOKENS.filter(func(t :Token) -> bool: return not ignore_tokens.has(t)): + + if t.match(input, current_index): + token = t + break + if token == OPERATOR_SUB: + token = tokenize_value(input, current_index, token) + if token == TOKEN_NOT_MATCH: + return tokenize_value(input, current_index, token, ignore_tokens.has(TOKEN_FUNCTION)) + return token + + +func tokenize_value(input: String, current: int, token: Token, ignore_dots := false) -> Token: + var next := 0 + var current_token := "" + # test for '--', '+-', '*-', '/-', '%-', or at least '-x' + var test_for_sign := (token == null or token.is_operator()) and input[current] == "-" + while current + next < len(input): + var character := input[current + next] as String + # if first charater a sign + # or allowend charset + # or is a float value + if (test_for_sign and next==0) \ + or is_allowed_character(character) \ + or (character == "." and (ignore_dots or current_token.is_valid_int())): + current_token += character + next += 1 + continue + break + if current_token != "": + return Variable.new(current_token) + return TOKEN_NOT_MATCH + + +# const ALLOWED_CHARACTERS := "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"" +func is_allowed_character(input: String) -> bool: + var code_point := input.unicode_at(0) + # Unicode + if code_point > 127: + # This is a Unicode character (Chinese, Japanese, etc.) + return true + # ASCII digit 0-9 + if code_point >= 48 and code_point <= 57: + return true + # ASCII lowercase a-z + if code_point >= 97 and code_point <= 122: + return true + # ASCII uppercase A-Z + if code_point >= 65 and code_point <= 90: + return true + # underscore _ + if code_point == 95: + return true + # quotes '" + if code_point == 34 or code_point == 39: + return true + return false + + +func parse_return_token(input: String) -> Variable: + var index := input.rfind(TOKEN_FUNCTION_RETURN_TYPE._token) + if index == -1: + return TOKEN_NOT_MATCH + index += TOKEN_FUNCTION_RETURN_TYPE._consumed + # We scan for the return value exclusive '.' token because it could be referenced to a + # external or internal class e.g. 'func foo() -> InnerClass.Bar:' + var token := next_token(input, index, [TOKEN_FUNCTION]) + while !token.is_variable() and token != TOKEN_NOT_MATCH: + index += token._consumed + token = next_token(input, index, [TOKEN_FUNCTION]) + return token + + +func get_function_descriptors(script: GDScript, included_functions: PackedStringArray = []) -> Array[GdFunctionDescriptor]: + var fds: Array[GdFunctionDescriptor] = [] + for method_descriptor in script.get_script_method_list(): + var func_name: String = method_descriptor["name"] + if included_functions.is_empty() or func_name in included_functions: + # exclude type set/geters + if is_getter_or_setter(func_name): + continue + if not fds.any(func(fd: GdFunctionDescriptor) -> bool: return fd.name() == func_name): + fds.append(GdFunctionDescriptor.extract_from(method_descriptor, false)) + + # we need to enrich it by default arguments and line number by parsing the script + # the engine core functions has no valid methods to get this info + _prescan_script(script) + _enrich_function_descriptor(script, fds) + return fds + + +func is_getter_or_setter(func_name: String) -> bool: + return func_name.begins_with("@") and (func_name.ends_with("getter") or func_name.ends_with("setter")) + + +func _parse_function_arguments(input: String) -> Array[Dictionary]: + var arguments: Array[Dictionary] = [] + var current_index := 0 + var token: Token = null + var bracket := 0 + var in_function := false + + + while current_index < len(input): + token = next_token(input, current_index) + # fallback to not end in a endless loop + if token == TOKEN_NOT_MATCH: + var error : = """ + Parsing Error: Invalid token at pos %d found. + Please report this error! + source_code: + -------------------------------------------------------------- + %s + -------------------------------------------------------------- + """.dedent() % [current_index, input] + push_error(error) + current_index += 1 + continue + current_index += token._consumed + if token.is_skippable(): + continue + if token == TOKEN_BRACKET_ROUND_OPEN : + in_function = true + bracket += 1 + if token == TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # if function end? + if in_function and bracket == 0: + return arguments + # is function + if token == TOKEN_FUNCTION_DECLARATION: + continue + + # is value argument + if in_function: + var arg_value := "" + var current_argument := { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : TYPE_VARIANT + } + + # parse type and default value + while current_index < len(input): + token = next_token(input, current_index) + current_index += token._consumed + if token.is_skippable(): + continue + + if token.is_variable() && current_argument["name"] == "": + arguments.append(current_argument) + current_argument["name"] = (token as Variable).plain_value() + continue + + match token: + # is fuzzer argument + TOKEN_ARGUMENT_FUZZER: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["name"] = (token as FuzzerToken).name() + current_argument["value"] = arg_value.lstrip(" ") + current_argument["type"] = TYPE_FUZZER + arguments.append(current_argument) + continue + + TOKEN_ARGUMENT_VARIADIC: + current_argument["type"] = TYPE_VARARG + + TOKEN_ARGUMENT_TYPE: + token = next_token(input, current_index) + if token == TOKEN_SPACE: + current_index += token._consumed + token = next_token(input, current_index) + current_index += token._consumed + if current_argument["type"] != TYPE_VARARG: + current_argument["type"] = GdObjects.string_to_type((token as Variable).plain_value()) + + TOKEN_ARGUMENT_TYPE_ASIGNMENT: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + TOKEN_ARGUMENT_ASIGNMENT: + token = next_token(input, current_index) + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + + TOKEN_BRACKET_SQUARE_OPEN: + bracket += 1 + TOKEN_BRACKET_CURLY_OPEN: + bracket += 1 + TOKEN_BRACKET_ROUND_OPEN : + bracket += 1 + # if value a function? + if bracket > 1: + # complete the argument value + var func_begin := input.substr(current_index-TOKEN_BRACKET_ROUND_OPEN ._consumed) + var func_body := _parse_end_function(func_begin) + arg_value += func_body + # fix parse index to end of value + current_index += func_body.length() - TOKEN_BRACKET_ROUND_OPEN ._consumed - TOKEN_BRACKET_ROUND_CLOSE._consumed + TOKEN_BRACKET_SQUARE_CLOSE: + bracket -= 1 + TOKEN_BRACKET_CURLY_CLOSE: + bracket -= 1 + TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # end of function + if bracket == 0: + break + TOKEN_ARGUMENT_SEPARATOR: + if bracket <= 1: + # next argument + current_argument = { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : GdObjects.TYPE_VARIANT + } + continue + return arguments + + +func _parse_end_function(input: String, remove_trailing_char := false) -> String: + # find end of function + var current_index := 0 + var bracket_count := 0 + var in_array := 0 + var in_dict := 0 + var end_of_func := false + + while current_index < len(input) and not end_of_func: + var character := input[current_index] + # step over strings + if character == "'" : + current_index = input.find("'", current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + if character == '"' : + # test for string blocks + if input.find('"""', current_index) == current_index: + current_index = input.find('"""', current_index+3) + 3 + else: + current_index = input.find('"', current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + + match character: + # count if inside an array + "[": in_array += 1 + "]": in_array -= 1 + # count if inside an dictionary + "{": in_dict += 1 + "}": in_dict -= 1 + # count if inside a function + "(": bracket_count += 1 + ")": + bracket_count -= 1 + if bracket_count < 0 and in_array <= 0 and in_dict <= 0: + end_of_func = true + ",": + if bracket_count == 0 and in_array == 0 and in_dict <= 0: + end_of_func = true + current_index += 1 + if remove_trailing_char: + # check if the parsed value ends with comma or end of doubled breaked + # `<value>,` or `<function>())` + var trailing_char := input[current_index-1] + if trailing_char == ',' or (bracket_count < 0 and trailing_char == ')'): + return input.substr(0, current_index-1) + return input.substr(0, current_index) + + +func extract_inner_class(source_rows: PackedStringArray, clazz_name :String) -> PackedStringArray: + for row_index in source_rows.size(): + var input := source_rows[row_index] + var token := next_token(input, 0) + if token.is_inner_class(): + @warning_ignore("unsafe_method_access") + if token.is_class_name(clazz_name): + @warning_ignore("unsafe_method_access") + token.parse(source_rows, row_index) + @warning_ignore("unsafe_method_access") + return token.content() + return PackedStringArray() + + +func extract_func_signature(rows: PackedStringArray, index: int) -> String: + var signature := "" + + for rowIndex in range(index, rows.size()): + var row := rows[rowIndex] + row = _regex_strip_comments.sub(row, "").strip_edges(false) + if row.is_empty(): + continue + signature += row + "\n" + if is_func_end(row): + return signature.strip_edges() + push_error("Can't fully extract function signature of '%s'" % rows[index]) + return "" + + +func get_class_name(script :GDScript) -> String: + var source_code := GdScriptParser.to_unix_format(script.source_code) + var source_rows := source_code.split("\n") + + for index :int in min(10, source_rows.size()): + var input := source_rows[index] + var token := next_token(input, 0) + if token == TOKEN_CLASS_NAME: + return token.value() + # if no class_name found extract from file name + return GdObjects.to_pascal_case(script.resource_path.get_basename().get_file()) + + +func parse_func_name(input: String) -> String: + if TOKEN_FUNCTION_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_DECLARATION.value() + if TOKEN_FUNCTION_STATIC_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_STATIC_DECLARATION.value() + push_error("Can't extract function name from '%s'" % input) + return "" + + +## Enriches the function descriptor by line number and argument default values +## - enrich all function descriptors form current script up to all inherited scrips +func _enrich_function_descriptor(script: GDScript, fds: Array[GdFunctionDescriptor]) -> void: + var enriched_functions := {} # Use Dictionary for O(1) lookup instead of PackedStringArray + var script_to_scan := script + while script_to_scan != null: + # do not scan the test suite base class itself + if script_to_scan.resource_path == "res://addons/gdUnit4/src/GdUnitTestSuite.gd": + break + + var rows := script_to_scan.source_code.split("\n") + for rowIndex in rows.size(): + var input := rows[rowIndex] + # step over inner class functions + if input.begins_with("\t"): + continue + # skip comments and empty lines + if input.begins_with("#") or input.length() == 0: + continue + var token := next_token(input, 0) + if token != TOKEN_FUNCTION_STATIC_DECLARATION and token != TOKEN_FUNCTION_DECLARATION: + continue + + var function_name: String = token.value() + # Skip if already enriched (from parent class scan) + if enriched_functions.has(function_name): + continue + + # Find matching function descriptor + var fd: GdFunctionDescriptor = null + for candidate in fds: + if candidate.name() == function_name: + fd = candidate + break + if fd == null: + continue + # Mark as enriched + enriched_functions[function_name] = true + var func_signature := extract_func_signature(rows, rowIndex) + var func_arguments := _parse_function_arguments(func_signature) + # enrich missing default values + fd.enrich_arguments(func_arguments) + fd.enrich_file_info(script_to_scan.resource_path, rowIndex + 1) + fd._is_coroutine = is_func_coroutine(rows, rowIndex) + # enrich return class name if not set + if fd.return_type() == TYPE_OBJECT and fd._return_class in ["", "Resource", "RefCounted"]: + var var_token := parse_return_token(func_signature) + if var_token != TOKEN_NOT_MATCH and var_token.type() == TYPE_OBJECT: + fd._return_class = _patch_inner_class_names(var_token.plain_value(), "") + # if the script ihnerits we need to scan this also + script_to_scan = script_to_scan.get_base_script() + + +func is_func_coroutine(rows :PackedStringArray, index :int) -> bool: + var is_coroutine := false + for rowIndex in range(index+1, rows.size()): + var input := rows[rowIndex].strip_edges() + if input.begins_with("#") or input.is_empty(): + continue + var token := next_token(input, 0) + # scan until next function + if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION: + break + + if _is_awaiting.search(input): + return true + return is_coroutine + + +func is_inner_class(clazz_path :PackedStringArray) -> bool: + return clazz_path.size() > 1 + + +func is_func_end(row :String) -> bool: + return row.strip_edges(false, true).ends_with(":") + + +func _patch_inner_class_names(clazz :String, clazz_name :String = "") -> String: + var inner_clazz_name := clazz.split(".")[0] + if _scanned_inner_classes.has(inner_clazz_name): + return inner_clazz_name + #var base_clazz := clazz_name.split(".")[0] + #return base_clazz + "." + clazz + if _script_constants.has(clazz): + return clazz_name + "." + clazz + return clazz + + +func _prescan_script(script: GDScript) -> void: + _script_constants = script.get_script_constant_map() + for key :String in _script_constants.keys(): + var value :Variant = _script_constants.get(key) + if value is GDScript: + @warning_ignore("return_value_discarded") + _scanned_inner_classes.append(key) + + +func parse(clazz_name :String, clazz_path :PackedStringArray) -> GdUnitResult: + if clazz_path.is_empty(): + return GdUnitResult.error("Invalid script path '%s'" % clazz_path) + var is_inner_class_ := is_inner_class(clazz_path) + var script :GDScript = load(clazz_path[0]) + _prescan_script(script) + + if is_inner_class_: + var inner_class_name := clazz_path[1] + if _scanned_inner_classes.has(inner_class_name): + # do load only on inner class source code and enrich the stored script instance + var source_code := _load_inner_class(script, inner_class_name) + script = _script_constants.get(inner_class_name) + script.source_code = source_code + var function_descriptors := get_function_descriptors(script) + var gd_class := GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors) + return GdUnitResult.success(gd_class) + + +func _load_inner_class(script: GDScript, inner_clazz: String) -> String: + var source_rows := GdScriptParser.to_unix_format(script.source_code).split("\n") + # extract all inner class names + var inner_class_code := extract_inner_class(source_rows, inner_clazz) + return "\n".join(inner_class_code) diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid new file mode 100644 index 0000000..11d95e1 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid @@ -0,0 +1 @@ +uid://djbak301ao2c0 diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd new file mode 100644 index 0000000..9faf830 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd @@ -0,0 +1,74 @@ +class_name GdUnitExpressionRunner +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ExpressionRunner extends '${clazz_path}' + +func __run_expression() -> Variant: + return $expression + +""" + +var constructor_args_regex := RegEx.create_from_string("new\\((?<args>.*)\\)") + + +func execute(src_script: GDScript, value: Variant) -> Variant: + if typeof(value) != TYPE_STRING: + return value + + var expression: String = value + var parameter_map := src_script.get_script_constant_map() + for key: String in parameter_map.keys(): + var parameter_value: Variant = parameter_map[key] + # check we need to construct from inner class + # we need to use the original class instance from the script_constant_map otherwise we run into a runtime error + if expression.begins_with(key + ".new") and parameter_value is GDScript: + var object: GDScript = parameter_value + var args := build_constructor_arguments(parameter_map, expression.substr(expression.find("new"))) + if args.is_empty(): + return object.new() + return object.callv("new", args) + + var script := GDScript.new() + var resource_path := "res://addons/gdUnit4/src/Fuzzers.gd" if src_script.resource_path.is_empty() else src_script.resource_path + script.source_code = CLASS_TEMPLATE.dedent()\ + .replace("${clazz_path}", resource_path)\ + .replace("$expression", expression) + #script.take_over_path(resource_path) + @warning_ignore("return_value_discarded") + script.reload(true) + var runner: Object = script.new() + if runner.has_method("queue_free"): + (runner as Node).queue_free() + @warning_ignore("unsafe_method_access") + return runner.__run_expression() + + +func build_constructor_arguments(parameter_map: Dictionary, expression: String) -> Array[Variant]: + var result := constructor_args_regex.search(expression) + var extracted_arguments := result.get_string("args").strip_edges() + if extracted_arguments.is_empty(): + return [] + var arguments :Array = extracted_arguments.split(",") + return arguments.map(func(argument: String) -> Variant: + var value := argument.strip_edges() + + # is argument an constant value + if parameter_map.has(value): + return parameter_map[value] + # is typed named value like Vector3.ONE + for type:int in GdObjects.TYPE_AS_STRING_MAPPINGS: + var type_as_string:String = GdObjects.TYPE_AS_STRING_MAPPINGS[type] + if value.begins_with(type_as_string): + return type_convert(value, type) + # is value a string + if value.begins_with("'") or value.begins_with('"'): + return value.trim_prefix("'").trim_suffix("'").trim_prefix('"').trim_suffix('"') + # fallback to default value converting + return str_to_var(value) + ) + + +func to_fuzzer(src_script: GDScript, expression: String) -> Fuzzer: + @warning_ignore("unsafe_cast") + return execute(src_script, expression) as Fuzzer diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid new file mode 100644 index 0000000..54da916 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid @@ -0,0 +1 @@ +uid://cstpl6o675qkp diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd new file mode 100644 index 0000000..527661d --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd @@ -0,0 +1,163 @@ +## @deprecated see GdFunctionParameterSetResolver +class_name GdUnitTestParameterSetResolver +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ParameterExtractor extends '${clazz_path}' + +func __extract_test_parameters() -> Array: + return ${test_params} + +""" + +const EXCLUDE_PROPERTIES_TO_COPY = [ + "script", + "type", + "Node", + "_import_path"] + + +var _fd: GdFunctionDescriptor +var _static_sets_by_index := {} +var _is_static := true + +func _init(fd: GdFunctionDescriptor) -> void: + _fd = fd + + +func is_parameterized() -> bool: + return _fd.is_parameterized() + + +func is_parameter_sets_static() -> bool: + return _is_static + + +func is_parameter_set_static(index: int) -> bool: + return _is_static and _static_sets_by_index.get(index, false) + + +# validates the given arguments are complete and matches to required input fields of the test function +func validate(parameter_sets: Array, parameter_set_index: int) -> GdUnitResult: + if parameter_sets.size() < parameter_set_index: + return GdUnitResult.error("Internal error: the resolved paremeterset has invalid size.") + + var input_values: Array = parameter_sets[parameter_set_index] + if input_values == null: + return GdUnitResult.error("The parameter set '%s' must be an Array!" % parameter_sets[parameter_set_index]) + + # check given parameter set with test case arguments + var input_arguments := _fd.args() + var expected_arg_count := input_arguments.size() - 1 #(-1 we exclude the parameter set itself) + var current_arg_count := input_values.size() + if current_arg_count != expected_arg_count: + var arg_names := input_arguments\ + .filter(func(arg: GdFunctionArgument) -> bool: return not arg.is_parameter_set())\ + .map(func(arg: GdFunctionArgument) -> String: return str(arg)) + + return GdUnitResult.error(""" + The test data set at index (%d) does not match the expected test arguments: + test function: [color=snow]func test...(%s)[/color] + test input values: [color=snow]%s[/color] + """ + .dedent() % [parameter_set_index, ",".join(arg_names), input_values]) + return GdUnitTestParameterSetResolver.validate_parameter_types(input_arguments, input_values) + + +static func validate_parameter_types(input_arguments: Array[GdFunctionArgument], input_values: Array) -> GdUnitResult: + for i in input_arguments.size(): + var input_param: GdFunctionArgument = input_arguments[i] + # only check the test input arguments + if input_param.is_parameter_set(): + continue + var input_param_type := input_param.type() + var input_value :Variant = input_values[i] + var input_value_type := typeof(input_value) + # input parameter is not typed or is Variant we skip the type test + if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT: + continue + # is input type enum allow int values + if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT: + continue + # allow only equal types and object == null + if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL: + continue + if input_param_type != input_value_type: + return GdUnitResult.error(""" + The test data value does not match the expected input type! + input value: [color=snow]'%s', <%s>[/color] + expected argument: [color=snow]%s[/color] + """ + .dedent() % [input_value, type_string(input_value_type), str(input_param)]) + return GdUnitResult.success("No errors found.") + + +func _extract_property_names(node :Node) -> PackedStringArray: + return node.get_property_list()\ + .map(func(property :Dictionary) -> String: return property["name"])\ + .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property)) + + +# tests if the test property set contains an property reference by name, if not the parameter set holds only static values +func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool: + for property_name in property_names: + if parameters.contains(property_name): + _is_static = false + return false + return true + + +# extracts the arguments from the given test case, using kind of reflection solution +# to restore the parameters from a string representation to real instance type +func load_parameter_sets(test_suite: Node) -> GdUnitResult: + var source_script: Script = test_suite.get_script() + var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args()) + var source_code := CLASS_TEMPLATE \ + .replace("${clazz_path}", source_script.resource_path) \ + .replace("${test_params}", parameter_arg.value_as_string()) + var script := GDScript.new() + script.source_code = source_code + # enable this lines only for debuging + #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name() + #DirAccess.remove_absolute(script.resource_path) + #ResourceSaver.save(script, script.resource_path) + var result := script.reload() + if result != OK: + return GdUnitResult.error("Extracting test parameters failed! Script loading error: %s" % error_string(result)) + var instance :Object = script.new() + GdUnitTestParameterSetResolver.copy_properties(test_suite, instance) + (instance as Node).queue_free() + var parameter_sets: Array = instance.call("__extract_test_parameters") + fixure_typed_parameters(parameter_sets, _fd.args()) + return GdUnitResult.success(parameter_sets) + + +func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array: + for parameter_set_index in parameter_sets.size(): + var parameter_set: Array = parameter_sets[parameter_set_index] + # run over all function arguments + for parameter_index in parameter_set.size(): + var parameter :Variant = parameter_set[parameter_index] + var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index] + if parameter is Array: + var as_array: Array = parameter + # we need to convert the untyped array to the expected typed version + if arg_descriptor.is_typed_array(): + parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null) + return parameter_sets + + +static func copy_properties(source: Object, dest: Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, "<empty>"); + continue + dest.set(property_name, property_value) diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid new file mode 100644 index 0000000..a12fb18 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://deasps2102or7 diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd b/addons/gdUnit4/src/core/report/GdUnitReport.gd new file mode 100644 index 0000000..aaf309f --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd @@ -0,0 +1,84 @@ +class_name GdUnitReport +extends Resource + +# report type +enum { + SUCCESS, + WARN, + FAILURE, + ORPHAN, + TERMINATED, + INTERUPTED, + ABORT, + SKIPPED, +} + +var _type :int +var _line_number :int +var _message :String +var _current_value: Variant + + +func create(p_type :int, p_line_number :int, p_message :String) -> GdUnitReport: + _type = p_type + _line_number = p_line_number + _message = p_message + return self + + +func with_current_value(value: Variant) -> GdUnitReport: + _current_value = value + return self + + +func type() -> int: + return _type + + +func line_number() -> int: + return _line_number + + +func message() -> String: + return _message + + +func is_skipped() -> bool: + return _type == SKIPPED + + +func is_warning() -> bool: + return _type == WARN + + +func is_failure() -> bool: + return _type == FAILURE + + +func is_error() -> bool: + return _type == TERMINATED or _type == INTERUPTED or _type == ABORT + + +func is_orphan() -> bool: + return _type == ORPHAN + + +func _to_string() -> String: + if _line_number == -1: + return "[color=green]line [/color][color=aqua]<n/a>:[/color] %s" % [_message] + return "[color=green]line [/color][color=aqua]%d:[/color] %s" % [_line_number, _message] + + +func serialize() -> Dictionary: + return { + "type" :_type, + "line_number" :_line_number, + "message" :_message + } + + +func deserialize(serialized :Dictionary) -> GdUnitReport: + _type = serialized["type"] + _line_number = serialized["line_number"] + _message = serialized["message"] + return self diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid new file mode 100644 index 0000000..934a280 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid @@ -0,0 +1 @@ +uid://c27ddgo83osir diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd new file mode 100644 index 0000000..34dcfa3 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd @@ -0,0 +1,470 @@ +#warning-ignore-all:return_value_discarded +class_name GdUnitTestCIRunner +extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd" +## Command line test runner implementation.[br] +## [br] +## This runner is designed for CI/CD pipelines and command line test execution.[br] +## Features:[br] +## - Command line options for test configuration[br] +## - HTML and JUnit report generation[br] +## - Console output with colored formatting[br] +## - Progress and error reporting[br] +## - Test history management[br] +## [br] +## Example usage:[br] +## [codeblock] +## # Run all tests in a directory +## runtest -a <directory> +## +## # Run specific test suite with ignored tests +## runtest -a <directory> -i <testsuite:test_name> +## [/codeblock] + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _console := GdUnitCSIMessageWriter.new() +var _console_reporter: GdUnitConsoleTestReporter +var _headless_mode_ignore := false +var _runner_config_file := "" +var _debug_cmd_args := PackedStringArray() +var _included_tests := PackedStringArray() +var _excluded_tests := PackedStringArray() + +## Command line options configuration +var _cmd_options := CmdOptions.new([ + CmdOption.new( + "-a, --add", + "-a <directory|path of testsuite>", + "Adds the given test suite or directory to the execution pipeline.", + TYPE_STRING + ), + CmdOption.new( + "-i, --ignore", + "-i <testsuite_name|testsuite_name:test-name>", + "Adds the given test suite or test case to the ignore list.", + TYPE_STRING + ), + CmdOption.new( + "-c, --continue", + "", + """By default GdUnit will abort checked first test failure to be fail fast, + instead of stop after first failure you can use this option to run the complete test set.""".dedent() + ), + CmdOption.new( + "-conf, --config", + "-conf [testconfiguration.cfg]", + "Run all tests by given test configuration. Default is 'GdUnitRunner.cfg'", + TYPE_STRING, + true + ), + CmdOption.new( + "-help", "", + "Shows this help message." + ), + CmdOption.new("--help-advanced", "", + "Shows advanced options." + ) + ], + [ + # advanced options + CmdOption.new( + "-rd, --report-directory", + "-rd <directory>", + "Specifies the output directory in which the reports are to be written. The default is res://reports/.", + TYPE_STRING, + true + ), + CmdOption.new( + "-rc, --report-count", + "-rc <count>", + "Specifies how many reports are saved before they are deleted. The default is %s." % str(GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT), + TYPE_INT, + true + ), + #CmdOption.new("--list-suites", "--list-suites [directory]", "Lists all test suites located in the given directory.", TYPE_STRING), + #CmdOption.new("--describe-suite", "--describe-suite <suite name>", "Shows the description of selected test suite.", TYPE_STRING), + CmdOption.new( + "--info", "", + "Shows the GdUnit version info" + ), + CmdOption.new( + "--selftest", "", + "Runs the GdUnit self test" + ), + CmdOption.new( + "--ignoreHeadlessMode", + "--ignoreHeadlessMode", + "By default, running GdUnit4 in headless mode is not allowed. You can switch off the headless mode check by set this property." + ), + ]) + + +func _init() -> void: + super() + + +func _ready() -> void: + super() + # stop checked first test failure to fail fast + _executor.fail_fast(true) + _console_reporter = GdUnitConsoleTestReporter.new(_console, true) + GdUnitSignals.instance().gdunit_message.connect(_on_send_message) + + +func _notification(what: int) -> void: + super(what) + if what == NOTIFICATION_PREDELETE: + prints("Finallize .. done") + + +func init_runner() -> void: + init_gd_unit() + + +## Returns the exit code based on test results.[br] +## Maps test report status to process exit codes. +func get_exit_code() -> int: + return report_exit_code() + + +## Cleanup and quit the runner.[br] +## [br] +## [param code] The exit code to return. +func quit(code: int) -> void: + _state = EXIT + GdUnitTools.dispose_all() + await GdUnitMemoryObserver.gc_on_guarded_instances() + await super(code) + + +## Prints info message to console.[br] +## [br] +## [param message] The message to print.[br] +## [param color] Optional color for the message. +func console_info(message: String, color: Color = Color.WHITE) -> void: + _console.color(color).println_message(message) + + +## Prints error message to console.[br] +## [br] +## [param message] The error message to print. +func console_error(message: String) -> void: + _console.prints_error(message) + + +## Prints warning message to console.[br] +## [br] +## [param message] The warning message to print. +func console_warning(message: String) -> void: + _console.prints_warning(message) + + +## Sets the directory for test reports.[br] +## [br] +## [param path] The path where reports should be written. +func set_report_dir(path: String) -> void: + report_base_path = ProjectSettings.globalize_path(GdUnitFileAccess.make_qualified_path(path)) + console_info( + "Set write reports to %s" % report_base_path, + Color.DEEP_SKY_BLUE + ) + + +## Sets how many report files to keep.[br] +## [br] +## [param count] The number of reports to keep. +func set_report_count(count: String) -> void: + var report_count := count.to_int() + if report_count < 1: + console_error( + "Invalid report history count '%s' set back to default %d" + % [count, GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT] + ) + max_report_history = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT + else: + console_info( + "Set report history count to %s" % count, + Color.DEEP_SKY_BLUE + ) + max_report_history = report_count + + +## Disables fail-fast mode to run all tests.[br] +## By default tests stop on first failure. +func disable_fail_fast() -> void: + console_info( + "Disabled fail fast!", + Color.DEEP_SKY_BLUE + ) + @warning_ignore("unsafe_method_access") + _executor.fail_fast(false) + + +func run_self_test() -> void: + console_info( + "Run GdUnit4 self tests.", + Color.DEEP_SKY_BLUE + ) + disable_fail_fast() + + + +## Shows GdUnit and Godot version information. +func show_version() -> void: + console_info( + "Godot %s" % Engine.get_version_info().get("string") as String, + Color.DARK_SALMON + ) + var config := ConfigFile.new() + config.load("addons/gdUnit4/plugin.cfg") + console_info( + "GdUnit4 %s" % config.get_value("plugin", "version") as String, + Color.DARK_SALMON + ) + quit(RETURN_SUCCESS) + + +## Ignores headless mode restrictions.[br] +## Allows tests to run in headless mode despite limitations. +func check_headless_mode() -> void: + _headless_mode_ignore = true + + +## Shows available command line options.[br] +## [br] +## [param show_advanced] Whether to show advanced options. +func show_options(show_advanced: bool = false) -> void: + console_info( + """ + Usage: + runtest -a <directory|path of testsuite> + runtest -a <directory> -i <path of testsuite|testsuite_name|testsuite_name:test_name> + """.dedent(), + Color.DARK_SALMON + ) + console_info( + "-- Options ---------------------------------------------------------------------------------------", + Color.DARK_SALMON + ) + for option in _cmd_options.default_options(): + descripe_option(option) + if show_advanced: + console_info( + "-- Advanced options --------------------------------------------------------------------------", + Color.DARK_SALMON + ) + for option in _cmd_options.advanced_options(): + descripe_option(option) + + +## Describes a single command line option.[br] +## [br] +## [param cmd_option] The option to describe. +func descripe_option(cmd_option: CmdOption) -> void: + console_info( + " %-40s" % str(cmd_option.commands()), + Color.CORNFLOWER_BLUE + ) + console_info( + cmd_option.description(), + Color.LIGHT_GREEN + ) + if not cmd_option.help().is_empty(): + console_info( + "%-4s %s" % ["", cmd_option.help()], + Color.DARK_TURQUOISE + ) + console_info("") + + +## Loads test configuration from file.[br] +## [br] +## [param path] Path to the configuration file. +func load_test_config(path := GdUnitRunnerConfig.CONFIG_FILE) -> void: + console_info( + "Loading test configuration %s\n" % path, + Color.CORNFLOWER_BLUE + ) + _runner_config_file = path + _runner_config.load_config(path) + + +## Shows basic help and exits. +func show_help() -> void: + show_options() + quit(RETURN_SUCCESS) + + +## Shows advanced help and exits. +func show_advanced_help() -> void: + show_options(true) + quit(RETURN_SUCCESS) + + +## Gets command line arguments.[br] +## Returns debug args if set, otherwise actual command line args. +func get_cmdline_args() -> PackedStringArray: + if _debug_cmd_args.is_empty(): + return OS.get_cmdline_args() + return _debug_cmd_args + + +## Initializes the test runner and processes command line arguments. +func init_gd_unit() -> void: + console_info( + """ + -------------------------------------------------------------------------------------------------- + GdUnit4 Comandline Tool + --------------------------------------------------------------------------------------------------""".dedent(), + Color.DARK_SALMON + ) + + var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd") + var result := cmd_parser.parse(get_cmdline_args()) + if result.is_error(): + console_error(result.error_message()) + show_options() + console_error("Abnormal exit with %d" % RETURN_ERROR) + quit(RETURN_ERROR) + return + if result.is_empty(): + show_help() + return + # build runner config by given commands + var commands :Array[CmdCommand] = [] + @warning_ignore("unsafe_cast") + commands.append_array(result.value() as Array) + result = ( + CmdCommandHandler.new(_cmd_options) + .register_cb("-help", show_help) + .register_cb("--help-advanced", show_advanced_help) + .register_cb("-a", add_test_suite) + .register_cbv("-a", add_test_suites) + .register_cb("-i", skip_test_suite) + .register_cbv("-i", skip_test_suites) + .register_cb("-rd", set_report_dir) + .register_cb("-rc", set_report_count) + .register_cb("--selftest", run_self_test) + .register_cb("-c", disable_fail_fast) + .register_cb("-conf", load_test_config) + .register_cb("--info", show_version) + .register_cb("--ignoreHeadlessMode", check_headless_mode) + .execute(commands) + ) + if result.is_error(): + console_error(result.error_message()) + quit(RETURN_ERROR) + return + + if DisplayServer.get_name() == "headless": + if _headless_mode_ignore: + console_warning(""" + Headless mode is ignored by option '--ignoreHeadlessMode'" + + Please note that tests that use UI interaction do not work correctly in headless mode. + Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore + have no effect in the test! + """.dedent() + ) + else: + console_error(""" + Headless mode is not supported! + + Please note that tests that use UI interaction do not work correctly in headless mode. + Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore + have no effect in the test! + + You can run with '--ignoreHeadlessMode' to swtich off this check. + """.dedent() + ) + console_error( + "Abnormal exit with %d" % RETURN_ERROR_HEADLESS_NOT_SUPPORTED + ) + quit(RETURN_ERROR_HEADLESS_NOT_SUPPORTED) + return + + _test_cases = discover_tests() + if _test_cases.is_empty(): + console_info("No test cases found, abort test run!", Color.YELLOW) + console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON) + quit(RETURN_SUCCESS) + return + _state = RUN + + +func discover_tests() -> Array[GdUnitTestCase]: + var gdunit_test_discover_added := GdUnitSignals.instance().gdunit_test_discover_added + + _test_cases = _runner_config.test_cases() + var scanner := GdUnitTestSuiteScanner.new() + for path in _included_tests: + var scripts := scanner.scan(path) + for script in scripts: + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + if not is_skipped(test): + #_console.println_message("discoverd %s" % test.display_name) + _test_cases.append(test) + gdunit_test_discover_added.emit(test) + ) + + return _test_cases + + +func add_test_suite(path: String) -> void: + _included_tests.append(path) + + +func add_test_suites(paths: PackedStringArray) -> void: + _included_tests.append_array(paths) + + +func skip_test_suite(path: String) -> void: + _excluded_tests.append(path) + + +func skip_test_suites(paths: PackedStringArray) -> void: + _excluded_tests.append_array(paths) + + +func is_skipped(test: GdUnitTestCase) -> bool: + for skipped_info in _excluded_tests: + + # is suite skipped by full path or suite name + if skipped_info == test.suite_name or test.source_file.contains(skipped_info): + return true + var skip_file := skipped_info.replace("res://", "") + + # check for skipped single test + if not skip_file.contains(":"): + continue + var parts: PackedStringArray = skip_file.rsplit(":") + var skipped_suite := parts[0] + var skipped_test := parts[1] + # is suite skipped by full path or suite name + if (skipped_suite == test.suite_name or test.source_file.contains(skipped_suite)) and skipped_test == test.test_name: + return true + + return false + + +func _on_send_message(message: String) -> void: + _console.color(Color.CORNFLOWER_BLUE).println_message(message) + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.SESSION_START: + _console_reporter.test_session = _test_session + GdUnitEvent.SESSION_CLOSE: + _console_reporter.test_session = null + + +func report_exit_code() -> int: + if _console_reporter.total_error_count() + _console_reporter.total_failure_count() > 0: + console_info("Exit code: %d" % RETURN_ERROR, Color.FIREBRICK) + return RETURN_ERROR + if _console_reporter.total_orphan_count() > 0: + console_info("Exit code: %d" % RETURN_WARNING, Color.GOLDENROD) + return RETURN_WARNING + console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON) + return RETURN_SUCCESS diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid new file mode 100644 index 0000000..58e1061 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid @@ -0,0 +1 @@ +uid://dllu6e8jp1nuk diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd new file mode 100644 index 0000000..0fd8fc7 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd @@ -0,0 +1,90 @@ +@tool +extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd" +## Runner implementation used by the editor UI.[br] +## [br] +## This runner connects to a GdUnit server via TCP to report test results.[br] +## Test results are reported in real-time and displayed in the editor UI.[br] +## [br] +## The runner uses an RPC message protocol to communicate status and events:[br] +## - Messages to report progress[br] +## - Events to report test results[br] + +## The TCP client used to connect to the GdUnit server +@onready var _client: GdUnitTcpClient = $GdUnitTcpClient +@onready var _version_label: Control = %Version + + +func _init() -> void: + super() + # We set the default max report history to 1 + max_report_history = 1 + + +func _ready() -> void: + super() + GdUnit4Version.init_version_label(_version_label) + + var config_result := _runner_config.load_config() + if config_result.is_error(): + push_error(config_result.error_message()) + _state = EXIT + return + @warning_ignore("return_value_discarded") + _client.connection_failed.connect(_on_connection_failed) + GdUnitSignals.instance().gdunit_message.connect(_on_send_message) + _executor.fail_fast(_runner_config.is_fail_fast()) + var result := _client.start("127.0.0.1", _runner_config.server_port()) + if result.is_error(): + push_error(result.error_message()) + return + + +## Cleanup and quit the runner.[br] +## [br] +## [param code] The exit code to return. +func quit(code: int) -> void: + if code != RETURN_SUCCESS: + _state = EXIT + await GdUnitMemoryObserver.gc_on_guarded_instances() + await super.quit(code) + + +## Called when the TCP connection to the GdUnit server fails.[br] +## Stops the test execution.[br] +## [br] +## [param message] The error message describing the failure. +func _on_connection_failed(message: String) -> void: + prints("_on_connection_failed", message) + _state = STOP + + +## Initializes the test runner.[br] +## Waits for TCP client connection and then scans for test suites.[br] +## Reports the number of found test suites via TCP message. +func init_runner() -> void: + # wait until client is connected to the GdUnitServer + if _client.is_client_connected(): + await gdUnitInit() + _state = RUN + + +## Initializes the GdUnit framework.[br] +## Sends initial message about number of test suites. +func gdUnitInit() -> void: + #enable_manuall_polling() + _test_cases = _runner_config.test_cases() + await get_tree().process_frame + + +## Sends a message via TCP to the GdUnit server.[br] +## [br] +## [param message] The message to send. +func _on_send_message(message: String) -> void: + _client.send(RPCMessage.of(message)) + + +## Handles GdUnit events by sending them via TCP to the server.[br] +## [br] +## [param event] The event to send. +func _on_gdunit_event(event: GdUnitEvent) -> void: + _client.send(RPCGdUnitEvent.of(event)) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid new file mode 100644 index 0000000..fd35f6b --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid @@ -0,0 +1 @@ +uid://cc77n13jrf5x6 diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn new file mode 100644 index 0000000..eaa6f1a --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=3 format=3 uid="uid://belidlfknh74r"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd" id="1"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpClient.gd" id="2"] + +[node name="Control" type="Node"] +script = ExtResource("1") + +[node name="GdUnitTcpClient" type="Node" parent="."] +script = ExtResource("2") + +[node name="HBoxContainer" type="HBoxContainer" parent="."] +custom_minimum_size = Vector2(0, 24) +layout_direction = 2 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 0 +size_flags_horizontal = 3 +size_flags_vertical = 10 +alignment = 2 + +[node name="Version" type="RichTextLabel" parent="HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(128, 0) +layout_mode = 2 +size_flags_horizontal = 10 +bbcode_enabled = true +scroll_active = false +shortcut_keys_enabled = false +horizontal_alignment = 1 +justification_flags = 0 diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd new file mode 100644 index 0000000..c789847 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd @@ -0,0 +1,169 @@ +## +## @since GdUnit4 5.1.0 +## +## Represents a test execution session in GdUnit4.[br] +## [br] +## [i]A test session encapsulates a complete test execution cycle, managing the collection +## of test cases to be executed and providing communication channels for test events +## and messages. This class serves as the central coordination point for test execution +## and allows hooks and other components to interact with the running test session.[/i][br] +## [br] +## [b][u]Key Features[/u][/b][br] +## - [i][b]Test Case Management[/b][/i]: Maintains a collection of test cases to be executed[br] +## - [i][b]Event Broadcasting[/b][/i]: Forwards GdUnit events to session-specific listeners[br] +## - [i][b]Message Communication[/b][/i]: Provides a channel for sending messages during test execution[br] +## - [i][b]Hook Integration[/b][/i]: Passed to test session hooks for startup and shutdown operations[br] +## [br] +## [b][u]Usage in Test Hooks[/u][/b] +## [codeblock] +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## # Access test cases +## print("Running %d test cases" % session.test_cases.size()) +## +## # Send status messages +## session.send_message("Custom hook initialized") +## +## # Listen for test events +## session.test_event.connect(_on_test_event) +## +## return GdUnitResult.success() +## +## func _on_test_event(event: GdUnitEvent) -> void: +## print("Test event received: %s" % event.type) +## [/codeblock] +## [br] +## [b][u]Event Flow[/u][/b][br] +## 1. Session is created with a collection of test cases[br] +## 2. Session connects to the global GdUnit event system[br] +## 3. During test execution, events are automatically forwarded to session listeners[br] +## 4. Hooks and other components can subscribe to session events[br] +## 5. Messages can be sent through the session for logging and communication[br] +class_name GdUnitTestSession +extends RefCounted + + +## Emitted when a test execution event occurs.[br] +## [br] +## [i]This signal forwards events from the global GdUnit event system to session-specific +## listeners. It allows hooks and other session components to react to test events +## without directly connecting to the global event system.[/i][br] +## [br] +## [u]Common event types include:[/u][br] +## - Test suite start/end events[br] +## - Test case start/end events[br] +## - Test assertion events[br] +## - Test failure/error events[br] +## +## [param event] The test event containing details about test execution, timing, and results +@warning_ignore("unused_signal") +signal test_event(event: GdUnitEvent) + + +## [b][color=red]@readonly: Should not be modified directly during test execution![/color][/b][br] +## Collection of test cases to be executed in this session.[br] +## [br] +## This array contains all the test cases that will be run during the session. +## Test hooks can access this collection to: +## - Get the total number of tests to be executed +## - Access individual test case metadata +## - Perform setup/teardown based on test case requirements +## - Generate reports or statistics about the test suite +## +## The collection is typically populated before session startup and remains +## constant during test execution. +var _test_cases : Array[GdUnitTestCase] = [] + + +## [b][color=red]@readonly: The report path should not be modified after session creation![/color][/b][br] +## The file system path where test reports for this session will be generated.[br] +## [br] +## [i]This property provides centralized access to the report output location, +## allowing test hooks, reporters, and other components to reference the same +## report path without coupling to specific reporter implementations.[/i][br] +## [br] +## [b][u]Common use cases include:[/u][/b][br] +## - Test hooks generating additional report files in the same directory[br] +## - Custom reporters creating supplementary output files[br] +## - Post-processing scripts that need to locate generated reports[br] +## - Cleanup operations that need to manage report artifacts[br] +## [br] +## [b][u]Example Usage:[/u][/b] +## [codeblock] +## # In a test hook +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## var report_dir = session.report_path.get_base_dir() +## var custom_report = report_dir.path_join("custom_metrics.json") +## # Generate additional reports in the same location +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Reports available at: " + session.report_path) +## return GdUnitResult.success() +## [/codeblock] +## [br] +## The path is set during session initialization and remains constant throughout +## the test execution lifecycle. +var report_path: String: + get: + return report_path + + +## Initializes the test session and sets up event forwarding.[br] +## [br] +## [i]This constructor automatically connects to the global GdUnit event system +## and forwards all events to the session's test_event signal. This allows +## session-specific components to listen for test events without managing +## global signal connections.[/i] +func _init(test_cases: Array[GdUnitTestCase], session_report_path: String) -> void: + # We build a copy to prevent a user is modifing the tests + _test_cases = test_cases.duplicate(true) + report_path = session_report_path + GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void: + test_event.emit(event) + ) + + +## Finds a test case by its unique identifier.[br] +## [br] +## [i]Searches through all test cases to find a test with the matching GUID.[/i][br] +## [br] +## [param id] The GUID of the test to find[br] +## Returns the matching test case or null if not found. +func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase: + for test in _test_cases: + if test.guid.equals(id): + return test + + return null + + +## Sends a message through the GdUnit messaging system.[br] +## [br] +## [i]This method provides a convenient way for test hooks and other session +## components to send messages that will be handled by the GdUnit framework.[/i] +## [br][br] +## [b][u]Messages are typically used for:[/u][/b][br] +## - Status updates during test execution[br] +## - Progress reporting from test hooks[br] +## - Debug information and logging[br] +## - User notifications and alerts[br] +## [br] +## The message will be processed by the global GdUnit message system and +## may be displayed in the test runner UI, logged to files, or handled +## by other registered message handlers. +## [br] +## [b][u]Example Usage:[/u][/b] +## [codeblock] +## # In a test hook +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Database connection established") +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Generated test report: report.html") +## return GdUnitResult.success() +## ``` +## [/codeblock] +## [param message] The message text to send through the GdUnit messaging system +func send_message(message: String) -> void: + GdUnitSignals.instance().gdunit_message.emit(message) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid new file mode 100644 index 0000000..aa729ff --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid @@ -0,0 +1 @@ +uid://dj37sbm0qh3sj diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd new file mode 100644 index 0000000..6551e08 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd @@ -0,0 +1,180 @@ +extends Node +## The base test runner implementation.[br] +## [br] +## This class provides the core functionality to execute test suites with following features:[br] +## - Loading and initialization of test suites[br] +## - Executing test suites and managing test states[br] +## - Event dispatching and test reporting[br] +## - Support for headless mode[br] +## - Plugin version verification[br] +## [br] +## Supported by specialized runners:[br] +## - [b]GdUnitTestRunner[/b]: Used in the editor, connects via tcp to report test results[br] +## - [b]GdUnitCLRunner[/b]: A command line interface runner, writes test reports to file[br] +## The test runner runs checked default in fail-fast mode, it stops checked first test failure. + +## Overall test run status codes used by the runners +const RETURN_SUCCESS = 0 +const RETURN_ERROR = 100 +const RETURN_ERROR_HEADLESS_NOT_SUPPORTED = 103 +const RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED = 104 +const RETURN_WARNING = 101 + +## Specifies the Node name under which the runner is registered +const GDUNIT_RUNNER = "GdUnitRunner" + +## The current runner configuration +@warning_ignore("unused_private_class_variable") +var _runner_config := GdUnitRunnerConfig.new() + +## The test suite executor instance +var _executor: GdUnitTestSuiteExecutor +var _hooks : GdUnitTestSessionHookService + +## Current runner state +var _state := READY + +## Current tests to be processed +var _test_cases: Array[GdUnitTestCase] = [] + + +## Configured report base path (can be set on CI test runner) +var report_base_path: String = GdUnitFileAccess.current_dir() + "reports": + get: + return report_base_path + + +## Current session report path +var report_path: String: + get: + return "%s/%s%d" % [report_base_path, GdUnitConstants.REPORT_DIR_PREFIX, current_report_history_index] + + +## Current report history index, if max_report_history > 1 we scan for the next index over the existing reports +var current_report_history_index: int: + get: + if max_report_history > 1: + return GdUnitFileAccess.find_last_path_index(report_base_path, GdUnitConstants.REPORT_DIR_PREFIX) + 1 + else: + return 1 + + +## Controls how many report historys will be hold +var max_report_history: int = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT: + get: + return max_report_history + set(value): + max_report_history = value + + +# holds the current test session context +var _test_session: GdUnitTestSession + +## Runner state machine +enum { + READY, + INIT, + RUN, + STOP, + EXIT +} + +func _init() -> void: + if OS.get_cmdline_args().size() == 1: + DisplayServer.window_set_title("GdUnit4 Runner (Debug Mode)") + else: + DisplayServer.window_set_title("GdUnit4 Runner (Release Mode)") + if not Engine.is_embedded_in_editor(): + # minimize scene window checked debug mode + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) + # store current runner instance to engine meta data to can be access in as a singleton + Engine.set_meta(GDUNIT_RUNNER, self) + + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + if Engine.get_version_info().hex < 0x40300: + printerr("The GdUnit4 plugin requires Godot version 4.3 or higher to run.") + quit(RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED) + return + _executor = GdUnitTestSuiteExecutor.new() + + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + _state = INIT + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + Engine.remove_meta(GDUNIT_RUNNER) + + +## Main test runner loop. Is called every frame to manage the test execution. +func _process(_delta: float) -> void: + match _state: + INIT: + await init_runner() + RUN: + _hooks = GdUnitTestSessionHookService.instance() + _test_session = GdUnitTestSession.new(_test_cases, report_path) + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionStart.new()) + # process next test suite + set_process(false) + var result := await _hooks.execute_startup(_test_session) + if result.is_error(): + push_error(result.error_message()) + await _executor.run_and_wait(_test_cases) + result = await _hooks.execute_shutdown(_test_session) + if result.is_error(): + push_error(result.error_message()) + _state = STOP + set_process(true) + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new()) + cleanup_report_history() + STOP: + _state = EXIT + # give the engine small amount time to finish the rpc + await get_tree().create_timer(0.1).timeout + await quit(get_exit_code()) + + +## Used by the inheriting runners to initialize test execution +func init_runner() -> void: + await get_tree().process_frame + + +func cleanup_report_history() -> int: + return GdUnitFileAccess.delete_path_index_lower_equals_than( + report_path.get_base_dir(), + GdUnitConstants.REPORT_DIR_PREFIX, + current_report_history_index-1-max_report_history) + + +## Returns the exit code when the test run is finished.[br] +## Abstract method to be implemented by the inheriting runners. +func get_exit_code() -> int: + return RETURN_SUCCESS + + +## Quits the test runner with given exit code. +func quit(code: int) -> void: + await get_tree().process_frame + await get_tree().physics_frame + get_tree().quit(code) + + +func prints_warning(message: String) -> void: + prints(message) + + +## Default event handler to process test events.[br] +## Should be overridden by concrete runner implementation. +@warning_ignore("unused_parameter") +func _on_gdunit_event(event: GdUnitEvent) -> void: + pass + + +## Event bridge from C# GdUnit4.ITestEventListener.cs[br] +## Used to handle test events from C# tests. +# gdlint: disable=function-name +func PublishEvent(data: Dictionary) -> void: + _on_gdunit_event(GdUnitEvent.new().deserialize(data)) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid new file mode 100644 index 0000000..cfd20db --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid @@ -0,0 +1 @@ +uid://wnlvwy04lmn6 diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd new file mode 100644 index 0000000..20325ac --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd @@ -0,0 +1,36 @@ +class_name GdUnitTestSuiteDefaultTemplate +extends RefCounted + + +const DEFAULT_TEMP_TS_GD =""" + # GdUnit generated TestSuite + class_name ${suite_class_name} + extends GdUnitTestSuite + @warning_ignore('unused_parameter') + @warning_ignore('return_value_discarded') + + # TestSuite generated from + const __source: String = '${source_resource_path}' +""" + + +const DEFAULT_TEMP_TS_CS = """ + // GdUnit generated TestSuite + + using Godot; + using GdUnit4; + + namespace ${name_space} + { + using static Assertions; + using static Utils; + + [TestSuite] + public class ${suite_class_name} + { + // TestSuite generated from + private const string sourceClazzPath = "${source_resource_path}"; + + } + } +""" diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid new file mode 100644 index 0000000..af9b576 --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid @@ -0,0 +1 @@ +uid://d36rdpq1g8nt1 diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd new file mode 100644 index 0000000..6fc282d --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd @@ -0,0 +1,144 @@ +class_name GdUnitTestSuiteTemplate +extends RefCounted + +const TEMPLATE_ID_GD = 1000 +const TEMPLATE_ID_CS = 2000 + +const SUPPORTED_TAGS_GD = """ + GdScript Tags are replaced when the test-suite is created. + + # The class name of the test-suite, formed from the source script. + ${suite_class_name} + # is used to build the test suite class name + class_name ${suite_class_name} + extends GdUnitTestSuite + + + # The class name in pascal case, formed from the source script. + ${source_class} + # can be used to create the class e.g. for source 'MyClass' + var my_test_class := ${source_class}.new() + # will be result in + var my_test_class := MyClass.new() + + # The class as variable name in snake case, formed from the source script. + ${source_var} + # Can be used to build the variable name e.g. for source 'MyClass' + var ${source_var} := ${source_class}.new() + # will be result in + var my_class := MyClass.new() + + # The full resource path from which the file was created. + ${source_resource_path} + # Can be used to load the script in your test + var my_script := load(${source_resource_path}) + # will be result in + var my_script := load("res://folder/my_class.gd") +""" + +const SUPPORTED_TAGS_CS = """ + C# Tags are replaced when the test-suite is created. + + // The namespace name of the test-suite + ${name_space} + namespace ${name_space} + + // The class name of the test-suite, formed from the source class. + ${suite_class_name} + // is used to build the test suite class name + [TestSuite] + public class ${suite_class_name} + + // The class name formed from the source class. + ${source_class} + // can be used to create the class e.g. for source 'MyClass' + private string myTestClass = new ${source_class}(); + // will be result in + private string myTestClass = new MyClass(); + + // The class as variable name in camelCase, formed from the source class. + ${source_var} + // Can be used to build the variable name e.g. for source 'MyClass' + private object ${source_var} = new ${source_class}(); + // will be result in + private object myClass = new MyClass(); + + // The full resource path from which the file was created. + ${source_resource_path} + // Can be used to load the script in your test + private object myScript = GD.Load(${source_resource_path}); + // will be result in + private object myScript = GD.Load("res://folder/MyClass.cs"); +""" + +const TAG_TEST_SUITE_CLASS = "${suite_class_name}" +const TAG_SOURCE_CLASS_NAME = "${source_class}" +const TAG_SOURCE_CLASS_VARNAME = "${source_var}" +const TAG_SOURCE_RESOURCE_PATH = "${source_resource_path}" + + +static func default_GD_template() -> String: + return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_GD.dedent().trim_prefix("\n") + + +static func default_CS_template() -> String: + return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_CS.dedent().trim_prefix("\n") + + +static func build_template(source_path: String) -> String: + var clazz_name :String = GdObjects.to_pascal_case(GdObjects.extract_class_name(source_path).value_as_string()) + var template: String = GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + + return template\ + .replace(TAG_TEST_SUITE_CLASS, clazz_name+"Test")\ + .replace(TAG_SOURCE_RESOURCE_PATH, source_path)\ + .replace(TAG_SOURCE_CLASS_NAME, clazz_name)\ + .replace(TAG_SOURCE_CLASS_VARNAME, GdObjects.to_snake_case(clazz_name)) + + +static func default_template(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "" + if template_id == TEMPLATE_ID_GD: + return default_GD_template() + return default_CS_template() + + +static func load_template(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "" + if template_id == TEMPLATE_ID_GD: + return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template()) + + +static func save_template(template_id :int, template :String) -> void: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return + if template_id == TEMPLATE_ID_GD: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, template.dedent().trim_prefix("\n")) + elif template_id == TEMPLATE_ID_CS: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, template.dedent().trim_prefix("\n")) + + +static func reset_to_default(template_id :int) -> void: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return + if template_id == TEMPLATE_ID_GD: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + else: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template()) + + +static func load_tags(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "Error checked loading tags" + if template_id == TEMPLATE_ID_GD: + return SUPPORTED_TAGS_GD + else: + return SUPPORTED_TAGS_CS diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid new file mode 100644 index 0000000..e901a3d --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid @@ -0,0 +1 @@ +uid://b2v71bhkym4ji diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd new file mode 100644 index 0000000..402199b --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd @@ -0,0 +1,70 @@ +class_name GdUnitThreadContext +extends RefCounted + +var _thread: Thread +var _thread_name: String +var _thread_id: int +var _signal_collector: GdUnitSignalCollector +var _execution_context: GdUnitExecutionContext +var _asserts := [] + + +func _init(thread: Thread = null) -> void: + if thread != null: + _thread = thread + _thread_name = thread.get_meta("name") + _thread_id = thread.get_id() as int + else: + _thread_name = "main" + _thread_id = OS.get_main_thread_id() + _signal_collector = GdUnitSignalCollector.new() + + +func dispose() -> void: + clear_assert() + if is_instance_valid(_signal_collector): + _signal_collector.clear() + _signal_collector = null + _execution_context = null + _thread = null + + +func terminate() -> void: + _execution_context.terminate() + + +func clear_assert() -> void: + _asserts.clear() + + +func set_assert(value: GdUnitAssert) -> void: + if value != null: + _asserts.append(value) + + +func get_assert() -> GdUnitAssert: + return null if _asserts.is_empty() else _asserts[-1] + + +func set_execution_context(context: GdUnitExecutionContext) -> void: + _execution_context = context + + +func get_execution_context() -> GdUnitExecutionContext: + return _execution_context + + +func get_execution_context_id() -> int: + return _execution_context.get_instance_id() + + +func get_signal_collector() -> GdUnitSignalCollector: + return _signal_collector + + +func thread_id() -> int: + return _thread_id + + +func _to_string() -> String: + return "ThreadContext <%s>: %s " % [_thread_name, _thread_id] diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid new file mode 100644 index 0000000..01dc8f6 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid @@ -0,0 +1 @@ +uid://c8t88hv3p8rvu diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd new file mode 100644 index 0000000..2e694e2 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd @@ -0,0 +1,69 @@ +## A manager to run new thread and crate a ThreadContext shared over the actual test run +class_name GdUnitThreadManager +extends Object + +## { <thread_id> = <GdUnitThreadContext> } +var _thread_context_by_id: Dictionary[int, GdUnitThreadContext] = {} +## holds the current thread id +var _current_thread_id :int = -1 + +func _init() -> void: + # add initail the main thread + _current_thread_id = OS.get_thread_caller_id() + _thread_context_by_id[OS.get_main_thread_id()] = GdUnitThreadContext.new() + + +static func instance() -> GdUnitThreadManager: + return GdUnitSingleton.instance("GdUnitThreadManager", func() -> GdUnitThreadManager: return GdUnitThreadManager.new()) + + +## Runs a new thread by given name and Callable.[br] +## A new GdUnitThreadContext is created, which is used for the actual test execution.[br] +## We need this custom implementation while this bug is not solved +## Godot issue https://github.com/godotengine/godot/issues/79637 +static func run(name :String, cb :Callable) -> Variant: + return await instance()._run(name, cb) + + +static func interrupt() -> void: + for thread_context: GdUnitThreadContext in instance()._thread_context_by_id.values(): + thread_context.terminate() + + +## Returns the current valid thread context +static func get_current_context() -> GdUnitThreadContext: + return instance()._get_current_context() + + +func _run(name :String, cb :Callable) -> Variant: + # we do this hack because of `OS.get_thread_caller_id()` not returns the current id + # when await process_frame is called inside the fread + var save_current_thread_id := _current_thread_id + var thread := Thread.new() + thread.set_meta("name", name) + @warning_ignore("return_value_discarded") + thread.start(cb) + _current_thread_id = thread.get_id() as int + _register_thread(thread, _current_thread_id) + var result :Variant = await thread.wait_to_finish() + _unregister_thread(_current_thread_id) + # restore original thread id + _current_thread_id = save_current_thread_id + return result + + +func _register_thread(thread :Thread, thread_id :int) -> void: + var context := GdUnitThreadContext.new(thread) + _thread_context_by_id[thread_id] = context + + +func _unregister_thread(thread_id :int) -> void: + var context: GdUnitThreadContext = _thread_context_by_id.get(thread_id) + if context: + @warning_ignore("return_value_discarded") + _thread_context_by_id.erase(thread_id) + context.dispose() + + +func _get_current_context() -> GdUnitThreadContext: + return _thread_context_by_id.get(_current_thread_id) diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid new file mode 100644 index 0000000..b943f92 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid @@ -0,0 +1 @@ +uid://7j6xocp0kaix diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd new file mode 100644 index 0000000..cc93ee2 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd @@ -0,0 +1,227 @@ +@tool +class_name GdUnitCSIMessageWriter +extends GdUnitMessageWriter +## A message writer implementation using ANSI/CSI escape codes for console output.[br] +## [br] +## This writer provides formatted message output using CSI (Control Sequence Introducer) codes.[br] +## It supports:[br] +## - Color using RGB values[br] +## - Text styles (bold, italic, underline)[br] +## - Cursor positioning and text alignment[br] +## [br] +## Used primarily for console-based test execution and CI/CD environments. + + +enum { + COLOR_TABLE, + COLOR_RGB +} + +const CSI_BOLD = "[1m" +const CSI_ITALIC = "[3m" +const CSI_UNDERLINE = "[4m" +const CSI_RESET = "[0m" + +# Control Sequence Introducer +var _debug_show_color_codes := false +var _color_mode := COLOR_TABLE + +## Current cursor position in the line +var _current_pos := 0 + +# Pre-compiled regex patterns for tag matching +var _tag_regex: RegEx + + +## Constructs CSI style codes based on flags.[br] +## [br] +## [param flags] The style flags to apply (BOLD, ITALIC, UNDERLINE).[br] +## Returns the corresponding CSI codes. +func _apply_style_flags(flags: int) -> String: + var _style := "" + if flags & BOLD: + _style += CSI_BOLD + if flags & ITALIC: + _style += CSI_ITALIC + if flags & UNDERLINE: + _style += CSI_UNDERLINE + return _style + + +## Converts a color string (named or hex) to a Color object +func _parse_color(color_str: String) -> Color: + return Color.from_string(color_str.strip_edges().to_lower(), Color.WHITE) + + +## Generates CSI color code for foreground color +func _color_to_csi_fg(c: Color) -> String: + return "[38;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a] + + +## Generates CSI color code for background color +func _color_to_csi_bg(c: Color) -> String: + return "[48;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a] + + +func _init_regex_patterns() -> void: + if not _tag_regex: + _tag_regex = RegEx.new() + # Match all richtext tags: [tag], [tag=value], [/tag] + _tag_regex.compile(r"\[/?(?:color|bgcolor|b|i|u)(?:=[^\]]+)?\]") + + +func _extract_color_from_tag(tag: String, tag_assign: String) -> Color: + var tag_assign_length := tag_assign.length() + var color_value := tag.substr(tag_assign_length, tag.length() - tag_assign_length - 1) + return _parse_color(color_value) + + +## Optimized richtext to CSI conversion using regex and lookup processing +func _bbcode_tags_to_csi_codes(message: String) -> String: + _init_regex_patterns() + + var result := "" + var last_pos := 0 + var color_stack: Array[Color] = [] + var bgcolor_stack: Array[Color] = [] + + # Find all richtext tags + var matches := _tag_regex.search_all(message) + + for match in matches: + var start_pos := match.get_start() + var end_pos := match.get_end() + var tag := match.get_string(0) + + # Add text before this tag + result += message.substr(last_pos, start_pos - last_pos) + + # Process the tag + if tag.begins_with("[color="): + var fg_color := _extract_color_from_tag(tag, "[color=") + color_stack.push_back(fg_color) + result += _color_to_csi_fg(fg_color) + elif tag.begins_with("[bgcolor="): + var bg_color := _extract_color_from_tag(tag, "[bgcolor=") + bgcolor_stack.push_back(bg_color) + result += _color_to_csi_bg(bg_color) + elif tag == "[b]": + result += CSI_BOLD + elif tag == "[i]": + result += CSI_ITALIC + elif tag == "[u]": + result += CSI_UNDERLINE + elif tag == "[/color]": + result += CSI_RESET + if color_stack.size() > 0: + color_stack.pop_back() + # Restore remaining styles and colors + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + elif tag == "[/bgcolor]": + result += CSI_RESET + if bgcolor_stack.size() > 0: + bgcolor_stack.pop_back() + # Restore remaining styles and colors + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + elif tag in ["[/b]", "[/i]", "[/u]"]: + result += CSI_RESET + # Restore remaining colors after style reset + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + + last_pos = end_pos + + # Add remaining text after last tag + result += message.substr(last_pos) + + return result + + +## Implementation of basic message output with formatting. +func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + var text := _bbcode_tags_to_csi_codes(_message) + var indent_text := "".lpad(_indent * 2) + var _style := _apply_style_flags(_flags) + printraw("%s[38;2;%d;%d;%dm%s%s[0m" % [indent_text, _color.r8, _color.g8, _color.b8, _style, text]) + _current_pos += _indent * 2 + text.length() + + +## Implementation of line-ending message output with formatting. +func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + _print_message(_message, _color, _indent, _flags) + prints() + _current_pos = 0 + + +## Implementation of positioned message output with formatting. +func _print_at(_message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void: + if _align == Align.RIGHT: + cursor_pos = cursor_pos - _message.length() + + if cursor_pos > _current_pos: + printraw("[%dG" % cursor_pos) # Move cursor to absolute position + else: + _message = " " + _message + + var _style := _apply_style_flags(_flags) + printraw("[38;2;%d;%d;%dm%s%s[0m" % [_color.r8, _color.g8, _color.b8, _style, _message]) + _current_pos = cursor_pos + _message.length() + + +## Writes a line break and returns self for chaining. +func new_line() -> GdUnitCSIMessageWriter: + prints() + return self + + +## Saves the current cursor position.[br] +## Returns self for chaining. +func save_cursor() -> GdUnitCSIMessageWriter: + printraw("[s") + return self + + +## Restores previously saved cursor position.[br] +## Returns self for chaining. +func restore_cursor() -> GdUnitCSIMessageWriter: + printraw("[u") + return self + + +## Clears screen content and resets cursor position. +func clear() -> void: + printraw("[2J[H") # Clear screen and move cursor to home + _current_pos = 0 + + +## Debug method to display the available color table.[br] +## Shows both 6x6x6 color cube and RGB color modes. +@warning_ignore("return_value_discarded") +func _print_color_table() -> void: + color(Color.ANTIQUE_WHITE).println_message("Color Table 6x6x6") + _debug_show_color_codes = true + for green in range(0, 6): + for red in range(0, 6): + for blue in range(0, 6): + color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ") + new_line() + new_line() + + color(Color.ANTIQUE_WHITE).println_message("Color Table RGB") + _color_mode = COLOR_RGB + for green in range(0, 6): + for red in range(0, 6): + for blue in range(0, 6): + color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ") + new_line() + new_line() + _color_mode = COLOR_TABLE + _debug_show_color_codes = false diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid new file mode 100644 index 0000000..e87dda7 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://bch3bjbfhr5lc diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd new file mode 100644 index 0000000..5244efa --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd @@ -0,0 +1,214 @@ +@tool +class_name GdUnitMessageWriter +extends RefCounted +## Base interface class for writing formatted messages to different outputs.[br] +## [br] +## This class defines the interface and common functionality for writing formatted messages.[br] +## It provides a fluent API for message formatting and supports different output targets.[br] +## [br] +## The class provides formatting options for:[br] +## - Text colors[br] +## - Text styles (bold, italic, underline)[br] +## - Text effects (e.g., wave)[br] +## - Text alignment[br] +## - Indentation[br] +## [br] +## Two concrete implementations are available:[br] +## - [GdUnitRichTextMessageWriter] writing to a [RichTextLabel][br] +## - [GdUnitCSIMessageWriter] writing to console using CSI codes[br] +## [br] +## Example usage:[br] +## [codeblock] +## writer.color(Color.RED).style(BOLD).println_message("Test failed!") +## writer.color(Color.GREEN).align(Align.RIGHT).print_at("Success", 80) +## [/codeblock] + + +## Text style flag for bold formatting +const BOLD = 0x1 +## Text style flag for italic formatting +const ITALIC = 0x2 +## Text style flag for underline formatting +const UNDERLINE = 0x4 + + +## Represents special text effects that can be applied to the output +enum Effect { + ## No special effect applied + NONE, + ## Applies a wave animation to the text + WAVE +} + + +## Controls text alignment at the specified cursor position +enum Align { + ## Aligns text to the left of the cursor position + LEFT, + ## Aligns text to the right of the cursor position, accounting for text length + RIGHT +} + + +## The current text color to be used for the next output operation +var _current_color := Color.WHITE + +## The current indentation level to be used for the next output operation.[br] +## Each level represents two spaces of indentation. +var _current_indent := 0 + +## The current text style flags (BOLD, ITALIC, UNDERLINE) to be used for the next output operation +var _current_flags := 0 + +## The current text alignment to be used for the next output operation +var _current_align := Align.LEFT + +## The current text effect to be used for the next output operation +var _current_effect := GdUnitMessageWriter.Effect.NONE + + +## Sets the text color for the next output operation.[br] +## [br] +## [param value] The color to be used for the text. +## Returns self for method chaining. +func color(value: Color) -> GdUnitMessageWriter: + _current_color = value + return self + + +## Sets the indentation level for the next output operation.[br] +## [br] +## [param value] The number of indentation levels, where each level equals two spaces. +## Returns self for method chaining. +func indent(value: int) -> GdUnitMessageWriter: + _current_indent = value + return self + + +## Sets text style flags for the next output operation.[br] +## [br] +## [param value] A combination of style flags (BOLD, ITALIC, UNDERLINE). +## Returns self for method chaining. +func style(value: int) -> GdUnitMessageWriter: + _current_flags = value + return self + + +## Sets text effect for the next output operation.[br] +## [br] +## [param value] The effect to apply to the text (NONE, WAVE). +## Returns self for method chaining. +func effect(value: GdUnitMessageWriter.Effect) -> GdUnitMessageWriter: + _current_effect = value + return self + + +## Sets text alignment for the next output operation.[br] +## [br] +## [param value] The alignment to use (LEFT, RIGHT). +## Returns self for method chaining. +func align(value: Align) -> GdUnitMessageWriter: + _current_align = value + return self + + +## Resets all formatting options to their default values.[br] +## [br] +## Defaults:[br] +## - color: Color.WHITE[br] +## - indent: 0[br] +## - flags: 0[br] +## - align: LEFT[br] +## - effect: NONE[br] +## Returns self for method chaining. +func reset() -> GdUnitMessageWriter: + _current_color = Color.WHITE + _current_indent = 0 + _current_flags = 0 + _current_align = Align.LEFT + _current_effect = Effect.NONE + return self + + +## Prints a warning message in golden color.[br] +## [br] +## [param message] The warning message to print. +func prints_warning(message: String) -> void: + color(Color.GOLDENROD).println_message(message) + + +## Prints an error message in crimson color.[br] +## [br] +## [param message] The error message to print. +func prints_error(message: String) -> void: + color(Color.CRIMSON).println_message(message) + + +## Prints a message with current formatting settings.[br] +## [br] +## [param message] The text to print. +func print_message(message: String) -> void: + _print_message(message, _current_color, _current_indent, _current_flags) + reset() + + +## Prints a message with current formatting settings followed by a newline.[br] +## [br] +## [param message] The text to print. +func println_message(message: String) -> void: + _println_message(message, _current_color, _current_indent, _current_flags) + reset() + + +## Prints a message at a specific column position with current formatting settings.[br] +## [br] +## [param message] The text to print.[br] +## [param cursor_pos] The column position where the text should start. +func print_at(message: String, cursor_pos: int) -> void: + _print_at(message, cursor_pos, _current_color, _current_effect, _current_align, _current_flags) + reset() + + +## Internal implementation of print_message.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param message] The text to print.[br] +## [param color] The color to use.[br] +## [param indent] The indentation level.[br] +## [param flags] The style flags to apply. +func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + pass + + +## Internal implementation of println_message.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param message] The text to print.[br] +## [param color] The color to use.[br] +## [param indent] The indentation level.[br] +## [param flags] The style flags to apply. +func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + pass + + +## Internal implementation of print_at.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param message] The text to print.[br] +## [param cursor_pos] The column position.[br] +## [param color] The color to use.[br] +## [param effect] The effect to apply.[br] +## [param align] The text alignment.[br] +## [param flags] The style flags to apply. +func _print_at(_message: String, _cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void: + pass + + +## Clears all output content.[br] +## [br] +## To be overridden by concrete formatters. +func clear() -> void: + pass diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid new file mode 100644 index 0000000..0fe00d2 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://5etcu5juhye8 diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd new file mode 100644 index 0000000..b9f79e4 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd @@ -0,0 +1,115 @@ +@tool +class_name GdUnitRichTextMessageWriter +extends GdUnitMessageWriter +## A message writer implementation using [RichTextLabel] for the test report UI.[br] +## [br] +## This writer implementation writes formatted messages to a [RichTextLabel] using BBCode.[br] +## It supports:[br] +## - Text formatting using BBCode (bold, italic, underline)[br] +## - Text coloring using push colors[br] +## - Text indentation using push indent[br] +## - Text effects like wave[br] +## - Basic cursor positioning[br] +## [br] +## Used to format test reports in the editor UI. + + +## The [RichTextLabel] instance to write formatted messages +var _output: RichTextLabel + +## Tracks current position in characters from line start +var _current_pos := 0 + + +## Creates a new message writer for the given [RichTextLabel].[br] +## [br] +## [param output] The [RichTextLabel] used for output. +func _init(output: RichTextLabel) -> void: + _output = output + + +## Applies text style flags by wrapping text in BBCode tags.[br] +## [br] +## Available styles:[br] +## - BOLD: [b]text[/b][br] +## - ITALIC: [i]text[/i][br] +## - UNDERLINE: [u]text[/u][br] +## [br] +## [param message] The text to format.[br] +## [param flags] The text style flags to apply. +func _apply_flags(message: String, flags: int) -> String: + if flags & BOLD: + message = "[b]%s[/b]" % message + if flags & ITALIC: + message = "[i]%s[/i]" % message + if flags & UNDERLINE: + message = "[u]%s[/u]" % message + return message + + +## Writes a message with formatting.[br] +## [br] +## [param message] The text to write.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param flags] The text style flags to apply. +func _print_message(message: String, _color: Color, _indent: int, flags: int) -> void: + for i in _indent: + _output.push_indent(1) + _output.push_color(_color) + message = _apply_flags(message, flags) + _output.append_text(message) + _output.pop() + for i in _indent: + _output.pop() + _current_pos += _indent * 2 + message.length() + + +## Writes a message with formatting followed by a line break.[br] +## [br] +## [param message] The text to write.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param flags] The text style flags to apply. +func _println_message(message: String, _color: Color, _indent: int, flags: int) -> void: + _print_message(message, _color, _indent, flags) + _output.newline() + _current_pos = 0 + + +## Writes a message at a specific column position.[br] +## [br] +## [param message] The text to write.[br] +## [param cursor_pos] The column position from line start.[br] +## [param _color] The color to use.[br] +## [param _effect] The text effect to apply (e.g. wave).[br] +## [param _align] The text alignment (left or right).[br] +## [param flags] The text style flags to apply. +func _print_at(message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, flags: int) -> void: + if _align == Align.RIGHT: + cursor_pos = cursor_pos - message.length() + + var spaces := cursor_pos - _current_pos + if spaces > 0: + _output.append_text("".lpad(spaces)) + _current_pos += spaces + else: + _output.append_text(" ") + _current_pos += 1 + + _output.push_color(_color) + message = _apply_flags(message, flags) + match _effect: + Effect.NONE: + pass + Effect.WAVE: + message = "[wave]%s[/wave]" % message + _output.append_text(message) + _output.pop() + _current_pos += message.length() + + +## Clears all written content from the [RichTextLabel]. +func clear() -> void: + _output.clear() + _current_pos = 0 diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid new file mode 100644 index 0000000..23fe6c0 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://cn0dawecdtlqx |
