summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'addons/gdUnit4/src/core')
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd127
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd224
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd726
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd65
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd232
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd109
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd139
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd642
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd443
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd129
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd118
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd56
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd97
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd20
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd410
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd144
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd11
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd114
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd253
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.pngbin0 -> 3292 bytes
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.png.import40
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd76
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd64
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd42
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd17
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd15
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd126
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd27
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd57
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd25
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd22
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd56
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd41
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd21
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd124
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd73
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd46
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd125
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd323
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd13
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd171
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd207
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd19
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd274
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd135
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd77
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd56
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd22
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd20
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd29
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd14
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd147
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd52
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd55
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd53
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd11
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd78
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd9
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd111
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd191
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd11
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd25
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd290
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd208
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd286
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd188
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd764
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd74
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd163
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd84
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd470
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd90
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn34
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd169
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd180
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd36
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd144
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd70
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd69
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd227
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd214
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd115
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid1
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
new file mode 100644
index 0000000..23f46ef
--- /dev/null
+++ b/addons/gdUnit4/src/core/assets/touch-button.png
Binary files differ
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 = ""
+const CSI_ITALIC = ""
+const CSI_UNDERLINE = ""
+const CSI_RESET = ""
+
+# 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" % [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" % [_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("")
+ return self
+
+
+## Restores previously saved cursor position.[br]
+## Returns self for chaining.
+func restore_cursor() -> GdUnitCSIMessageWriter:
+ printraw("")
+ return self
+
+
+## Clears screen content and resets cursor position.
+func clear() -> void:
+ printraw("") # 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