diff --git a/addons/gdUnit4/LICENSE b/addons/gdUnit4/LICENSE new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/bin/GdUnitCmdTool.gd b/addons/gdUnit4/bin/GdUnitCmdTool.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/bin/GdUnitCmdTool.gd.uid b/addons/gdUnit4/bin/GdUnitCmdTool.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/bin/GdUnitCopyLog.gd b/addons/gdUnit4/bin/GdUnitCopyLog.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/bin/GdUnitCopyLog.gd.uid b/addons/gdUnit4/bin/GdUnitCopyLog.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/plugin.cfg b/addons/gdUnit4/plugin.cfg new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/plugin.gd b/addons/gdUnit4/plugin.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/plugin.gd.uid b/addons/gdUnit4/plugin.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/runtest.cmd b/addons/gdUnit4/runtest.cmd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/runtest.sh b/addons/gdUnit4/runtest.sh new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/Comparator.gd b/addons/gdUnit4/src/Comparator.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/Comparator.gd.uid b/addons/gdUnit4/src/Comparator.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/Fuzzers.gd b/addons/gdUnit4/src/Fuzzers.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/Fuzzers.gd.uid b/addons/gdUnit4/src/Fuzzers.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd b/addons/gdUnit4/src/GdUnitArrayAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitAssert.gd b/addons/gdUnit4/src/GdUnitAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitAssert.gd.uid b/addons/gdUnit4/src/GdUnitAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd b/addons/gdUnit4/src/GdUnitAwaiter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd.uid b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd b/addons/gdUnit4/src/GdUnitBoolAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitConstants.gd b/addons/gdUnit4/src/GdUnitConstants.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitConstants.gd.uid b/addons/gdUnit4/src/GdUnitConstants.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd b/addons/gdUnit4/src/GdUnitFailureAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd b/addons/gdUnit4/src/GdUnitFileAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd.uid b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd b/addons/gdUnit4/src/GdUnitFloatAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd b/addons/gdUnit4/src/GdUnitFuncAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd b/addons/gdUnit4/src/GdUnitIntAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd.uid b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd b/addons/gdUnit4/src/GdUnitObjectAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd b/addons/gdUnit4/src/GdUnitResultAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd.uid b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd b/addons/gdUnit4/src/GdUnitSceneRunner.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd b/addons/gdUnit4/src/GdUnitSignalAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd b/addons/gdUnit4/src/GdUnitStringAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd.uid b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd b/addons/gdUnit4/src/GdUnitTestSuite.gd new file mode 100644 index 00000000..48cde7ac --- /dev/null +++ b/addons/gdUnit4/src/GdUnitTestSuite.gd @@ -0,0 +1,691 @@ +## The main class for all GdUnit test suites[br] +## This class is the main class to implement your unit tests[br] +## You have to extend and implement your test cases as described[br] +## e.g MyTests.gd [br] +## [codeblock] +## extends GdUnitTestSuite +## # testcase +## func test_case_a(): +## assert_that("value").is_equal("value") +## [/codeblock] +## @tutorial: https://mikeschulze.github.io/gdUnit4/faq/test-suite/ + +@icon("res://addons/gdUnit4/src/ui/settings/logo.png") +class_name GdUnitTestSuite +extends Node + +const NO_ARG :Variant = GdUnitConstants.NO_ARG + +### internal runtime variables that must not be overwritten!!! +@warning_ignore("unused_private_class_variable") +var __is_skipped := false +@warning_ignore("unused_private_class_variable") +var __skip_reason :String = "Unknow." +var __active_test_case :String +var __awaiter := __gdunit_awaiter() + + +### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading" +### in order to noticeably reduce the loading time of the test suite. +# We go this hard way to increase the loading performance to avoid reparsing all the used scripts +# for more detailed info -> https://github.com/godotengine/godot/issues/67400 +func __lazy_load(script_path :String) -> GDScript: + return GdUnitAssertions.__lazy_load(script_path) + + +func __gdunit_assert() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd") + + +func __gdunit_tools() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func __gdunit_file_access() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/core/GdUnitFileAccess.gd") + + +func __gdunit_awaiter() -> Object: + return __lazy_load("res://addons/gdUnit4/src/GdUnitAwaiter.gd").new() + + +func __gdunit_argument_matchers() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd") + + +func __gdunit_object_interactions() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd") + + +## This function is called before a test suite starts[br] +## You can overwrite to prepare test data or initalizize necessary variables +func before() -> void: + pass + + +## This function is called at least when a test suite is finished[br] +## You can overwrite to cleanup data created during test running +func after() -> void: + pass + + +## This function is called before a test case starts[br] +## You can overwrite to prepare test case specific data +func before_test() -> void: + pass + + +## This function is called after the test case is finished[br] +## You can overwrite to cleanup your test case specific data +func after_test() -> void: + pass + + +func is_failure(_expected_failure :String = NO_ARG) -> bool: + return Engine.get_meta("GD_TEST_FAILURE") if Engine.has_meta("GD_TEST_FAILURE") else false + + +func set_active_test_case(test_case :String) -> void: + __active_test_case = test_case + + +# === Tools ==================================================================== +# Mapps Godot error number to a readable error message. See at ERROR +# https://docs.godotengine.org/de/stable/classes/class_@globalscope.html#enum-globalscope-error +func error_as_string(error_number :int) -> String: + return error_string(error_number) + + +## A litle helper to auto freeing your created objects after test execution +func auto_free(obj :Variant) -> Variant: + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + + assert(execution_context != null, "INTERNAL ERROR: The current execution_context is null! Please report this as bug.") + return execution_context.register_auto_free(obj) + + +@warning_ignore("native_method_override") +func add_child(node :Node, force_readable_name := false, internal := Node.INTERNAL_MODE_DISABLED) -> void: + super.add_child(node, force_readable_name, internal) + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + if execution_context != null: + execution_context.orphan_monitor_start() + + +## Discard the error message triggered by a timeout (interruption).[br] +## By default, an interrupted test is reported as an error.[br] +## This function allows you to change the message to Success when an interrupted error is reported. +func discard_error_interupted_by_timeout() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_tools().register_expect_interupted_by_timeout(self, __active_test_case) + + +## Creates a new directory under the temporary directory *user://tmp*[br] +## Useful for storing data during test execution. [br] +## The directory is automatically deleted after test suite execution +func create_temp_dir(relative_path :String) -> String: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().create_temp_dir(relative_path) + + +## Deletes the temporary base directory[br] +## Is called automatically after each execution of the test suite +func clean_temp_dir() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_file_access().clear_tmp() + + +## Creates a new file under the temporary directory *user://tmp* + [br] +## with given name and given file (default = File.WRITE)[br] +## If success the returned File is automatically closed after the execution of the test suite +func create_temp_file(relative_path :String, file_name :String, mode := FileAccess.WRITE) -> FileAccess: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().create_temp_file(relative_path, file_name, mode) + + +## Reads a resource by given path into a PackedStringArray. +func resource_as_array(resource_path :String) -> PackedStringArray: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().resource_as_array(resource_path) + + +## Reads a resource by given path and returned the content as String. +func resource_as_string(resource_path :String) -> String: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().resource_as_string(resource_path) + + +## Reads a resource by given path and return Variand translated by str_to_var +func resource_as_var(resource_path :String) -> Variant: + @warning_ignore("unsafe_method_access", "unsafe_cast") + return str_to_var(__gdunit_file_access().resource_as_string(resource_path) as String) + + +## Waits for given signal to be emitted by until a specified timeout to fail[br] +## source: the object from which the signal is emitted[br] +## signal_name: signal name[br] +## args: the expected signal arguments as an array[br] +## timeout: the timeout in ms, default is set to 2000ms +func await_signal_on(source :Object, signal_name :String, args :Array = [], timeout :int = 2000) -> Variant: + @warning_ignore("unsafe_method_access") + return await __awaiter.await_signal_on(source, signal_name, args, timeout) + + +## Waits until the next idle frame +func await_idle_frame() -> void: + @warning_ignore("unsafe_method_access") + await __awaiter.await_idle_frame() + + +## Waits for a given amount of milliseconds[br] +## example:[br] +## [codeblock] +## # waits for 100ms +## await await_millis(myNode, 100).completed +## [/codeblock][br] +## use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out +func await_millis(timeout :int) -> void: + @warning_ignore("unsafe_method_access") + await __awaiter.await_millis(timeout) + + +## Creates a new scene runner to allow simulate interactions checked a scene.[br] +## The runner will manage the scene instance and release after the runner is released[br] +## example:[br] +## [codeblock] +## # creates a runner by using a instanciated scene +## var scene = load("res://foo/my_scne.tscn").instantiate() +## var runner := scene_runner(scene) +## +## # or simply creates a runner by using the scene resource path +## var runner := scene_runner("res://foo/my_scne.tscn") +## [/codeblock] +func scene_runner(scene :Variant, verbose := false) -> GdUnitSceneRunner: + return auto_free(__lazy_load("res://addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd").new(scene, verbose)) + + +# === Mocking & Spy =========================================================== + +## do return a default value for primitive types or null +const RETURN_DEFAULTS = GdUnitMock.RETURN_DEFAULTS +## do call the real implementation +const CALL_REAL_FUNC = GdUnitMock.CALL_REAL_FUNC +## do return a default value for primitive types and a fully mocked value for Object types +## builds full deep mocked object +const RETURN_DEEP_STUB = GdUnitMock.RETURN_DEEP_STUB + + +## Creates a mock for given class name +func mock(clazz :Variant, mock_mode := RETURN_DEFAULTS) -> Variant: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd").build(clazz, mock_mode) + + +## Creates a spy checked given object instance +func spy(instance :Variant) -> Variant: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd").build(instance) + + +## Configures a return value for the specified function and used arguments.[br] +## [b]Example: +## [codeblock] +## # overrides the return value of myMock.is_selected() to false +## do_return(false).on(myMock).is_selected() +## [/codeblock] +func do_return(value :Variant) -> GdUnitMock: + return GdUnitMock.new(value) + + +## Verifies certain behavior happened at least once or exact number of times +func verify(obj :Variant, times := 1) -> Variant: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify(obj, times) + + +## Verifies no interactions is happen checked this mock or spy +func verify_no_interactions(obj :Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify_no_interactions(obj) + + +## Verifies the given mock or spy has any unverified interaction. +func verify_no_more_interactions(obj :Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify_no_more_interactions(obj) + + +## Resets the saved function call counters checked a mock or spy +func reset(obj :Variant) -> void: + @warning_ignore("unsafe_method_access") + __gdunit_object_interactions().reset(obj) + + +## Starts monitoring the specified source to collect all transmitted signals.[br] +## The collected signals can then be checked with 'assert_signal'.[br] +## By default, the specified source is automatically released when the test ends. +## You can control this behavior by setting auto_free to false if you do not want the source to be automatically freed.[br] +## Usage: +## [codeblock] +## var emitter := monitor_signals(MyEmitter.new()) +## # call the function to send the signal +## emitter.do_it() +## # verify the signial is emitted +## await assert_signal(emitter).is_emitted('my_signal') +## [/codeblock] +func monitor_signals(source :Object, _auto_free := true) -> Object: + @warning_ignore("unsafe_method_access") + __lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd")\ + .get_current_context()\ + .get_signal_collector()\ + .register_emitter(source, true) # force recreate to start with a fresh monitoring + return auto_free(source) if _auto_free else source + + +# === Argument matchers ======================================================== +## Argument matcher to match any argument +func any() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().any() + + +## Argument matcher to match any boolean value +func any_bool() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_BOOL) + + +## Argument matcher to match any integer value +func any_int() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_INT) + + +## Argument matcher to match any float value +func any_float() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_FLOAT) + + +## Argument matcher to match any String value +func any_string() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_STRING) + + +## Argument matcher to match any Color value +func any_color() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_COLOR) + + +## Argument matcher to match any Vector typed value +func any_vector() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_types([ + TYPE_VECTOR2, + TYPE_VECTOR2I, + TYPE_VECTOR3, + TYPE_VECTOR3I, + TYPE_VECTOR4, + TYPE_VECTOR4I, + ]) + + +## Argument matcher to match any Vector2 value +func any_vector2() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR2) + + +## Argument matcher to match any Vector2i value +func any_vector2i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR2I) + + +## Argument matcher to match any Vector3 value +func any_vector3() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR3) + + +## Argument matcher to match any Vector3i value +func any_vector3i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR3I) + + +## Argument matcher to match any Vector4 value +func any_vector4() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR4) + + +## Argument matcher to match any Vector4i value +func any_vector4i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR4I) + + +## Argument matcher to match any Rect2 value +func any_rect2() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_RECT2) + + +## Argument matcher to match any Plane value +func any_plane() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PLANE) + + +## Argument matcher to match any Quaternion value +func any_quat() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_QUATERNION) + + +## Argument matcher to match any AABB value +func any_aabb() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_AABB) + + +## Argument matcher to match any Basis value +func any_basis() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_BASIS) + + +## Argument matcher to match any Transform2D value +func any_transform_2d() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM2D) + + +## Argument matcher to match any Transform3D value +func any_transform_3d() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM3D) + + +## Argument matcher to match any NodePath value +func any_node_path() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_NODE_PATH) + + +## Argument matcher to match any RID value +func any_rid() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_RID) + + +## Argument matcher to match any Object value +func any_object() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_OBJECT) + + +## Argument matcher to match any Dictionary value +func any_dictionary() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_DICTIONARY) + + +## Argument matcher to match any Array value +func any_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_ARRAY) + + +## Argument matcher to match any PackedByteArray value +func any_packed_byte_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_BYTE_ARRAY) + + +## Argument matcher to match any PackedInt32Array value +func any_packed_int32_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT32_ARRAY) + + +## Argument matcher to match any PackedInt64Array value +func any_packed_int64_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT64_ARRAY) + + +## Argument matcher to match any PackedFloat32Array value +func any_packed_float32_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT32_ARRAY) + + +## Argument matcher to match any PackedFloat64Array value +func any_packed_float64_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT64_ARRAY) + + +## Argument matcher to match any PackedStringArray value +func any_packed_string_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_STRING_ARRAY) + + +## Argument matcher to match any PackedVector2Array value +func any_packed_vector2_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR2_ARRAY) + + +## Argument matcher to match any PackedVector3Array value +func any_packed_vector3_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR3_ARRAY) + + +## Argument matcher to match any PackedColorArray value +func any_packed_color_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_COLOR_ARRAY) + + +## Argument matcher to match any instance of given class +func any_class(clazz :Object) -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().any_class(clazz) + + +# === value extract utils ====================================================== +## Builds an extractor by given function name and optional arguments +func extr(func_name :String, args := Array()) -> GdUnitValueExtractor: + return __lazy_load("res://addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd").new(func_name, args) + + +## Constructs a tuple by given arguments +func tuple(arg0 :Variant, + 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) -> GdUnitTuple: + return GdUnitTuple.new(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) + + +# === Asserts ================================================================== + +## The common assertion tool to verify values. +## It checks the given value by type to fit to the best assert +func assert_that(current :Variant) -> GdUnitAssert: + match typeof(current): + TYPE_BOOL: + return assert_bool(current) + TYPE_INT: + return assert_int(current) + TYPE_FLOAT: + return assert_float(current) + TYPE_STRING: + return assert_str(current) + TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I: + return assert_vector(current, false) + TYPE_DICTIONARY: + return assert_dict(current) + TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,\ + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,\ + TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY: + return assert_array(current, false) + TYPE_OBJECT, TYPE_NIL: + return assert_object(current) + _: + return __gdunit_assert().new(current) + + +## An assertion tool to verify boolean values. +func assert_bool(current :Variant) -> GdUnitBoolAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd").new(current) + + +## An assertion tool to verify String values. +func assert_str(current :Variant) -> GdUnitStringAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd").new(current) + + +## An assertion tool to verify integer values. +func assert_int(current :Variant) -> GdUnitIntAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd").new(current) + + +## An assertion tool to verify float values. +func assert_float(current :Variant) -> GdUnitFloatAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd").new(current) + + +## An assertion tool to verify Vector values.[br] +## This assertion supports all vector types.[br] +## Usage: +## [codeblock] +## assert_vector(Vector2(1.2, 1.000001)).is_equal(Vector2(1.2, 1.000001)) +## [/codeblock] +func assert_vector(current :Variant, type_check := true) -> GdUnitVectorAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd").new(current, type_check) + + +## An assertion tool to verify arrays. +func assert_array(current :Variant, type_check := true) -> GdUnitArrayAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd").new(current, type_check) + + +## An assertion tool to verify dictionaries. +func assert_dict(current :Variant) -> GdUnitDictionaryAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd").new(current) + + +## An assertion tool to verify FileAccess. +func assert_file(current :Variant) -> GdUnitFileAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd").new(current) + + +## An assertion tool to verify Objects. +func assert_object(current :Variant) -> GdUnitObjectAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd").new(current) + + +func assert_result(current :Variant) -> GdUnitResultAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd").new(current) + + +## An assertion tool that waits until a certain time for an expected function return value +func assert_func(instance :Object, func_name :String, args := Array()) -> GdUnitFuncAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd").new(instance, func_name, args) + + +## An assertion tool to verify for emitted signals until a certain time. +func assert_signal(instance :Object) -> GdUnitSignalAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd").new(instance) + + +## An assertion tool to test for failing assertions.[br] +## This assert is only designed for internal use to verify failing asserts working as expected.[br] +## Usage: +## [codeblock] +## assert_failure(func(): assert_bool(true).is_not_equal(true)) \ +## .has_message("Expecting:\n 'true'\n not equal to\n 'true'") +## [/codeblock] +func assert_failure(assertion :Callable) -> GdUnitFailureAssert: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute(assertion) + + +## An assertion tool to test for failing assertions.[br] +## This assert is only designed for internal use to verify failing asserts working as expected.[br] +## Usage: +## [codeblock] +## await assert_failure_await(func(): assert_bool(true).is_not_equal(true)) \ +## .has_message("Expecting:\n 'true'\n not equal to\n 'true'") +## [/codeblock] +func assert_failure_await(assertion :Callable) -> GdUnitFailureAssert: + @warning_ignore("unsafe_method_access") + return await __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute_and_await(assertion) + + +## An assertion tool to verify Godot errors.[br] +## You can use to verify certain Godot errors like failing assertions, push_error, push_warn.[br] +## Usage: +## [codeblock] +## # tests no error occurred during execution of the code +## await assert_error(func (): return 0 )\ +## .is_success() +## +## # tests a push_error('test error') occured during execution of the code +## await assert_error(func (): push_error('test error') )\ +## .is_push_error('test error') +## [/codeblock] +func assert_error(current :Callable) -> GdUnitGodotErrorAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd").new(current) + + +## Explicitly fails the current test indicating that the feature is not yet implemented.[br] +## This function is useful during development when you want to write test cases before implementing the actual functionality.[br] +## It provides a clear indication that the test failure is expected because the feature is still under development.[br] +## Usage: +## [codeblock] +## # Test for a feature that will be implemented later +## func test_advanced_ai_behavior(): +## assert_not_yet_implemented() +## +## [/codeblock] +func assert_not_yet_implemented() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_assert().new(null).do_fail() + + +## Explicitly fails the current test with a custom error message.[br] +## This function reports an error but does not terminate test execution automatically.[br] +## You must use 'return' after calling fail() to stop the test since GDScript has no exception support.[br] +## Useful for complex conditional testing scenarios where standard assertions are insufficient.[br] +## Usage: +## [codeblock] +## # Fail test when conditions are not met +## if !custom_check(player): +## fail("Player should be alive but has %d health" % player.health) +## return +## +## # Continue with test if conditions pass +## assert_that(player.health).is_greater(0) +## [/codeblock] +func fail(message: String) -> void: + @warning_ignore("unsafe_method_access") + __gdunit_assert().new(null).report_error(message) + + +# --- internal stuff do not override!!! +func ResourcePath() -> String: + return get_script().resource_path diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd.uid b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitTuple.gd b/addons/gdUnit4/src/GdUnitTuple.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitTuple.gd.uid b/addons/gdUnit4/src/GdUnitTuple.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd b/addons/gdUnit4/src/GdUnitValueExtractor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd b/addons/gdUnit4/src/GdUnitVectorAssert.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd b/addons/gdUnit4/src/asserts/GdAssertMessages.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd b/addons/gdUnit4/src/asserts/GdAssertReports.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd new file mode 100644 index 00000000..730b5da9 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd @@ -0,0 +1,436 @@ +class_name GdUnitArrayAssertImpl +extends GdUnitArrayAssert + + +var _base: GdUnitAssertImpl +var _current_value_provider: ValueProvider +var _type_check: bool + + +func _init(current: Variant, type_check := true) -> void: + _type_check = type_check + _current_value_provider = DefaultValueProvider.new(current) + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not _validate_value_type(current): + @warning_ignore("return_value_discarded") + report_error("GdUnitArrayAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event: int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func report_success() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func failure_message() -> String: + return _base.failure_message() + + +func override_failure_message(message: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func _validate_value_type(value: Variant) -> bool: + return value == null or GdArrayTools.is_array_type(value) + + +func get_current_value() -> Variant: + return _current_value_provider.get_value() + + +func max_length(left: Variant, right: Variant) -> int: + var ls := str(left).length() + var rs := str(right).length() + return rs if ls < rs else ls + + +# gdlint: disable=function-name +func _toPackedStringArray(value: Variant) -> PackedStringArray: + if GdArrayTools.is_array_type(value): + @warning_ignore("unsafe_cast") + return PackedStringArray(value as Array) + return PackedStringArray([str(value)]) + + +func _array_equals_div(current: Variant, expected: Variant, case_sensitive: bool = false) -> Array[Array]: + var current_value := _toPackedStringArray(current) + var expected_value := _toPackedStringArray(expected) + var index_report := Array() + for index in current_value.size(): + var c := current_value[index] + if index < expected_value.size(): + var e := expected_value[index] + if not GdObjects.equals(c, e, case_sensitive): + var length := max_length(c, e) + current_value[index] = GdAssertMessages.format_invalid(c.lpad(length)) + expected_value[index] = e.lpad(length) + index_report.push_back({"index": index, "current": c, "expected": e}) + else: + current_value[index] = GdAssertMessages.format_invalid(c) + index_report.push_back({"index": index, "current": c, "expected": ""}) + + for index in range(current_value.size(), expected_value.size()): + var value := expected_value[index] + expected_value[index] = GdAssertMessages.format_invalid(value) + index_report.push_back({"index": index, "current": "", "expected": value}) + return [current_value, expected_value, index_report] + + +func _array_div(compare_mode: GdObjects.COMPARE_MODE, left: Array[Variant], right: Array[Variant], _same_order := false) -> Array[Variant]: + var not_expect := left.duplicate(true) + var not_found := right.duplicate(true) + for index_c in left.size(): + var c: Variant = left[index_c] + for index_e in right.size(): + var e: Variant = right[index_e] + if GdObjects.equals(c, e, false, compare_mode): + GdArrayTools.erase_value(not_expect, e) + GdArrayTools.erase_value(not_found, c) + break + return [not_expect, not_found] + + +func _contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var by_reference := compare_mode == GdObjects.COMPARE_MODE.OBJECT_REFERENCE + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], expected_value, by_reference)) + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant]) + #var not_expect := diffs[0] as Array + var not_found: Array = diffs[1] + if not not_found.is_empty(): + return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], not_found, by_reference)) + return report_success() + + +func _contains_exactly(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly(null, expected_value, [], expected_value, compare_mode)) + # has same content in same order + if _is_equal(current_value, expected_value, false, compare_mode): + return report_success() + # check has same elements but in different order + if _is_equals_sorted(current_value, expected_value, false, compare_mode): + return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, [], [], compare_mode)) + # find the difference + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, + current_value as Array[Variant], + expected_value as Array[Variant], + GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + var not_expect: Array[Variant] = diffs[0] + var not_found: Array[Variant] = diffs[1] + return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, not_expect, not_found, compare_mode)) + + +func _contains_exactly_in_any_order(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [], + expected_value, compare_mode)) + # find the difference + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant], false) + var not_expect: Array[Variant] = diffs[0] + var not_found: Array[Variant] = diffs[1] + if not_expect.is_empty() and not_found.is_empty(): + return report_success() + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, not_expect, + not_found, compare_mode)) + + +func _not_contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [], + expected_value, compare_mode)) + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant]) + var found: Array[Variant] = diffs[0] + @warning_ignore("unsafe_cast") + if found.size() == (current_value as Array).size(): + return report_success() + @warning_ignore("unsafe_cast") + var diffs2 := _array_div(compare_mode, expected_value as Array[Variant], diffs[1] as Array[Variant]) + return report_error(GdAssertMessages.error_arr_not_contains(current_value, expected_value, diffs2[0], compare_mode)) + + +func is_null() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant= _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null and expected_value != null: + return report_error(GdAssertMessages.error_equal(null, expected_value)) + + if not _is_equal(current_value, expected_value): + var diff := _array_equals_div(current_value, expected_value) + var expected_as_list := GdArrayTools.as_string(diff[0], false) + var current_as_list := GdArrayTools.as_string(diff[1], false) + var index_report: Array = diff[2] + return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report)) + return report_success() + + +# Verifies that the current Array is equal to the given one, ignoring case considerations. +func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null and expected_value != null: + @warning_ignore("unsafe_cast") + return report_error(GdAssertMessages.error_equal(null, GdArrayTools.as_string(expected_value))) + + if not _is_equal(current_value, expected_value, true): + @warning_ignore("unsafe_cast") + var diff := _array_equals_div(current_value, expected_value, true) + var expected_as_list := GdArrayTools.as_string(diff[0]) + var current_as_list := GdArrayTools.as_string(diff[1]) + var index_report: Array = diff[2] + return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report)) + return report_success() + + +func is_not_equal(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if _is_equal(current_value, expected_value): + return report_error(GdAssertMessages.error_not_equal(current_value, expected_value)) + return report_success() + + +func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if _is_equal(current_value, expected_value, true): + @warning_ignore("unsafe_cast") + var c := GdArrayTools.as_string(current_value as Array) + @warning_ignore("unsafe_cast") + var e := GdArrayTools.as_string(expected_value) + return report_error(GdAssertMessages.error_not_equal_case_insensetiv(c, e)) + return report_success() + + +func is_empty() -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value == null or (current_value as Array).size() > 0: + return report_error(GdAssertMessages.error_is_empty(current_value)) + return report_success() + + +func is_not_empty() -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value != null and (current_value as Array).size() == 0: + return report_error(GdAssertMessages.error_is_not_empty()) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_same(expected: Variant) -> GdUnitArrayAssert: + if not _validate_value_type(expected): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected)) + var current: Variant = get_current_value() + if not is_same(current, expected): + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_is_same(current, expected)) + return self + + +func is_not_same(expected: Variant) -> GdUnitArrayAssert: + if not _validate_value_type(expected): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected)) + var current: Variant = get_current_value() + if is_same(current, expected): + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_not_same(current, expected)) + return self + + +func has_size(expected: int) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value == null or (current_value as Array).size() != expected: + return report_error(GdAssertMessages.error_has_size(current_value, expected)) + return report_success() + + +func contains(...expected: Array) -> GdUnitArrayAssert: + return _contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_exactly(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_same(...expected: Array) -> GdUnitArrayAssert: + return _contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func not_contains(...expected: Array) -> GdUnitArrayAssert: + return _not_contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func not_contains_same(...expected: Array) -> GdUnitArrayAssert: + return _not_contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func is_instanceof(expected: Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + _base.is_instanceof(expected) + return self + + +func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert: + var extracted_elements := Array() + var args: Array = _extract_variadic_value(func_args) + var extractor := GdUnitFuncValueExtractor.new(func_name, args) + var current: Variant = get_current_value() + if current == null: + _current_value_provider = DefaultValueProvider.new(null) + else: + for element: Variant in current: + extracted_elements.append(extractor.extract_value(element)) + _current_value_provider = DefaultValueProvider.new(extracted_elements) + return self + + +func extractv(...extractors: Array) -> GdUnitArrayAssert: + var extracted_elements := Array() + var current: Variant = get_current_value() + if current == null: + _current_value_provider = DefaultValueProvider.new(null) + else: + for element: Variant in current: + var ev: Array[Variant] = [ + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG, + GdUnitTuple.NO_ARG + ] + + for index: int in extractors.size(): + var extractor: GdUnitValueExtractor = extractors[index] + ev[index] = extractor.extract_value(element) + if extractors.size() > 1: + extracted_elements.append(GdUnitTuple.new(ev[0], ev[1], ev[2], ev[3], ev[4], ev[5], ev[6], ev[7], ev[8], ev[9])) + else: + extracted_elements.append(ev[0]) + _current_value_provider = DefaultValueProvider.new(extracted_elements) + return self + + +## Small helper to support the old expected arguments as single array and variadic arguments +func _extract_variadic_value(values: Variant) -> Variant: + @warning_ignore("unsafe_method_access") + if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]): + return values[0] + return values + + +@warning_ignore("incompatible_ternary") +func _is_equal( + left: Variant, + right: Variant, + case_sensitive := false, + compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + + @warning_ignore("unsafe_cast") + return GdObjects.equals( + (left as Array) if GdArrayTools.is_array_type(left) else left, + (right as Array) if GdArrayTools.is_array_type(right) else right, + case_sensitive, + compare_mode + ) + + +func _is_equals_sorted( + left: Variant, + right: Variant, + case_sensitive := false, + compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + + @warning_ignore("unsafe_cast") + return GdObjects.equals_sorted( + left as Array, + right as Array, + case_sensitive, + compare_mode) diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd new file mode 100644 index 00000000..c57bbc23 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd @@ -0,0 +1,207 @@ +extends GdUnitDictionaryAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_DICTIONARY): + @warning_ignore("return_value_discarded") + report_error("GdUnitDictionaryAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func report_success() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func failure_message() -> String: + return _base.failure_message() + + +func override_failure_message(message: String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func current_value() -> Variant: + return _base.current_value() + + +func is_null() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected))) + if not GdObjects.equals(current, expected): + var c := GdAssertMessages.format_dict(current) + var e := GdAssertMessages.format_dict(expected) + return report_error(GdAssertMessages.error_equal(c, e)) + return report_success() + + +func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert: + var current: Variant = current_value() + if GdObjects.equals(current, expected): + return report_error(GdAssertMessages.error_not_equal(current, expected)) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_same(expected :Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected))) + if not is_same(current, expected): + var c := GdAssertMessages.format_dict(current) + var e := GdAssertMessages.format_dict(expected) + return report_error(GdAssertMessages.error_is_same(c, e)) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_not_same(expected :Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if is_same(current, expected): + return report_error(GdAssertMessages.error_not_same(current, expected)) + return report_success() + + +func is_empty() -> GdUnitDictionaryAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or not (current as Dictionary).is_empty(): + return report_error(GdAssertMessages.error_is_empty(current)) + return report_success() + + +func is_not_empty() -> GdUnitDictionaryAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as Dictionary).is_empty(): + return report_error(GdAssertMessages.error_is_not_empty()) + return report_success() + + +func has_size(expected: int) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + @warning_ignore("unsafe_cast") + if (current as Dictionary).size() != expected: + return report_error(GdAssertMessages.error_has_size(current, expected)) + return report_success() + + +func _contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected_value: Array = _extract_variadic_value(expected) + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + # find expected keys + @warning_ignore("unsafe_cast") + var keys_not_found :Array = expected_value.filter(_filter_by_key.bind((current as Dictionary).keys(), compare_mode)) + if not keys_not_found.is_empty(): + @warning_ignore("unsafe_cast") + return report_error(GdAssertMessages.error_contains_keys((current as Dictionary).keys() as Array, expected_value, + keys_not_found, compare_mode)) + return report_success() + + +func _contains_key_value(key :Variant, value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected := [key] + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + var dict_current: Dictionary = current + var keys_not_found :Array = expected.filter(_filter_by_key.bind(dict_current.keys(), compare_mode)) + if not keys_not_found.is_empty(): + return report_error(GdAssertMessages.error_contains_keys(dict_current.keys() as Array, expected, keys_not_found, compare_mode)) + if not GdObjects.equals(dict_current[key], value, false, compare_mode): + return report_error(GdAssertMessages.error_contains_key_value(key, value, dict_current[key], compare_mode)) + return report_success() + + +func _not_contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected_value: Array = _extract_variadic_value(expected) + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + var dict_current: Dictionary = current + var keys_found :Array = dict_current.keys().filter(_filter_by_key.bind(expected_value, compare_mode, true)) + if not keys_found.is_empty(): + return report_error(GdAssertMessages.error_not_contains_keys(dict_current.keys() as Array, expected_value, keys_found, compare_mode)) + return report_success() + + +func contains_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert: + return _contains_key_value(key, value, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _not_contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_same_keys(expected :Array) -> GdUnitDictionaryAssert: + return _contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert: + return _contains_key_value(key, value, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _not_contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func _filter_by_key(element :Variant, values :Array, compare_mode :GdObjects.COMPARE_MODE, is_not :bool = false) -> bool: + for key :Variant in values: + if GdObjects.equals(key, element, false, compare_mode): + return is_not + return !is_not + + +## Small helper to support the old expected arguments as single array and variadic arguments +func _extract_variadic_value(values: Variant) -> Variant: + @warning_ignore("unsafe_method_access") + if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]): + return values[0] + return values diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd new file mode 100644 index 00000000..c38acf08 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd @@ -0,0 +1,177 @@ +extends GdUnitFuncAssert + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const DEFAULT_TIMEOUT := 2000 + + +var _current_value_provider :ValueProvider +var _current_failure_message :String = "" +var _custom_failure_message :String = "" +var _additional_failure_message: String = "" +var _line_number := -1 +var _timeout := DEFAULT_TIMEOUT +var _interrupted := false +var _sleep_timer :Timer = null + + +func _init(instance :Object, func_name :String, args := Array()) -> void: + _line_number = GdUnitAssertions.get_line_number() + GdAssertReports.reset_last_error_line_number() + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + # verify at first the function name exists + if not instance.has_method(func_name): + @warning_ignore("return_value_discarded") + report_error("The function '%s' do not exists checked instance '%s'." % [func_name, instance]) + _interrupted = true + else: + _current_value_provider = CallBackValueProvider.new(instance, func_name, args) + + +func _notification(what :int) -> void: + if what == NOTIFICATION_PREDELETE: + _interrupted = true + var main_node :Node = (Engine.get_main_loop() as SceneTree).root + if is_instance_valid(_current_value_provider): + _current_value_provider.dispose() + _current_value_provider = null + if is_instance_valid(_sleep_timer): + _sleep_timer.set_wait_time(0.0001) + _sleep_timer.stop() + main_node.remove_child(_sleep_timer) + _sleep_timer.free() + _sleep_timer = null + + +func report_success() -> GdUnitFuncAssert: + GdAssertReports.report_success() + return self + + +func report_error(failure :String) -> GdUnitFuncAssert: + _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(_current_failure_message, _line_number) + return self + + +func failure_message() -> String: + return _current_failure_message + + +func override_failure_message(message: String) -> GdUnitFuncAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitFuncAssert: + _additional_failure_message = message + return self + + +func wait_until(timeout := 2000) -> GdUnitFuncAssert: + if timeout <= 0: + push_warning("Invalid timeout param, alloed timeouts must be grater than 0. Use default timeout instead") + _timeout = DEFAULT_TIMEOUT + else: + _timeout = timeout + return self + + +func is_null() -> GdUnitFuncAssert: + await _validate_callback(cb_is_null) + return self + + +func is_not_null() -> GdUnitFuncAssert: + await _validate_callback(cb_is_not_null) + return self + + +func is_false() -> GdUnitFuncAssert: + await _validate_callback(cb_is_false) + return self + + +func is_true() -> GdUnitFuncAssert: + await _validate_callback(cb_is_true) + return self + + +func is_equal(expected: Variant) -> GdUnitFuncAssert: + await _validate_callback(cb_is_equal, expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitFuncAssert: + await _validate_callback(cb_is_not_equal, expected) + return self + + +# we need actually to define this Callable as functions otherwise we results into leaked scripts here +# this is actually a Godot bug and needs this kind of workaround +func cb_is_null(c :Variant, _e :Variant) -> bool: return c == null +func cb_is_not_null(c :Variant, _e :Variant) -> bool: return c != null +func cb_is_false(c :Variant, _e :Variant) -> bool: return c == false +func cb_is_true(c :Variant, _e :Variant) -> bool: return c == true +func cb_is_equal(c :Variant, e :Variant) -> bool: return GdObjects.equals(c,e) +func cb_is_not_equal(c :Variant, e :Variant) -> bool: return not GdObjects.equals(c, e) + + +func do_interrupt() -> void: + _interrupted = true + + +func _validate_callback(predicate :Callable, expected :Variant = null) -> void: + if _interrupted: + return + GdUnitMemoryObserver.guard_instance(self) + var time_scale := Engine.get_time_scale() + var timer := Timer.new() + timer.set_name("gdunit_funcassert_interrupt_timer_%d" % timer.get_instance_id()) + var scene_tree := Engine.get_main_loop() as SceneTree + scene_tree.root.add_child(timer) + timer.add_to_group("GdUnitTimers") + @warning_ignore("return_value_discarded") + timer.timeout.connect(do_interrupt, CONNECT_DEFERRED) + timer.set_one_shot(true) + timer.start((_timeout/1000.0)*time_scale) + _sleep_timer = Timer.new() + _sleep_timer.set_name("gdunit_funcassert_sleep_timer_%d" % _sleep_timer.get_instance_id() ) + scene_tree.root.add_child(_sleep_timer) + + while true: + var current :Variant = await next_current_value() + # is interupted or predicate success + if _interrupted or predicate.call(current, expected): + break + if is_instance_valid(_sleep_timer): + _sleep_timer.start(0.05) + await _sleep_timer.timeout + + _sleep_timer.stop() + await scene_tree.process_frame + if _interrupted: + # https://github.com/godotengine/godot/issues/73052 + #var predicate_name = predicate.get_method() + var predicate_name :String = str(predicate).split('::')[1] + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_interrupted( + predicate_name.strip_edges().trim_prefix("cb_"), + expected, + LocalTime.elapsed(_timeout) + ) + ) + else: + @warning_ignore("return_value_discarded") + report_success() + _sleep_timer.free() + timer.free() + GdUnitMemoryObserver.unguard_instance(self) + + +func next_current_value() -> Variant: + @warning_ignore("redundant_await") + if is_instance_valid(_current_value_provider): + return await _current_value_provider.get_value() + return "invalid value" diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd b/addons/gdUnit4/src/asserts/ValueProvider.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd.uid b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd b/addons/gdUnit4/src/cmd/CmdCommand.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd.uid b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd b/addons/gdUnit4/src/cmd/CmdOption.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd.uid b/addons/gdUnit4/src/cmd/CmdOption.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd b/addons/gdUnit4/src/cmd/CmdOptions.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd.uid b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd b/addons/gdUnit4/src/core/GdArrayTools.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd.uid b/addons/gdUnit4/src/core/GdArrayTools.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd b/addons/gdUnit4/src/core/GdDiffTool.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd.uid b/addons/gdUnit4/src/core/GdDiffTool.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdObjects.gd b/addons/gdUnit4/src/core/GdObjects.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdObjects.gd.uid b/addons/gdUnit4/src/core/GdObjects.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd b/addons/gdUnit4/src/core/GdUnit4Version.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd.uid b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd b/addons/gdUnit4/src/core/GdUnitFileAccess.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd b/addons/gdUnit4/src/core/GdUnitProperty.gd new file mode 100644 index 00000000..3d050b19 --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd b/addons/gdUnit4/src/core/GdUnitResult.gd new file mode 100644 index 00000000..42392a5e --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd b/addons/gdUnit4/src/core/GdUnitSettings.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd.uid b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd b/addons/gdUnit4/src/core/GdUnitSignals.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd.uid b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd b/addons/gdUnit4/src/core/GdUnitSingleton.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd new file mode 100644 index 00000000..33b80e28 --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd new file mode 100644 index 00000000..63028970 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd @@ -0,0 +1,404 @@ +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]: + 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 "--" + + +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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd b/addons/gdUnit4/src/core/GdUnitTools.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd.uid b/addons/gdUnit4/src/core/GdUnitTools.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd b/addons/gdUnit4/src/core/GodotVersionFixures.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/LocalTime.gd b/addons/gdUnit4/src/core/LocalTime.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/LocalTime.gd.uid b/addons/gdUnit4/src/core/LocalTime.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/_TestCase.gd b/addons/gdUnit4/src/core/_TestCase.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/_TestCase.gd.uid b/addons/gdUnit4/src/core/_TestCase.gd.uid new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitCommand.gd b/addons/gdUnit4/src/core/command/GdUnitCommand.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitCommand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommand.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcutAction.gd b/addons/gdUnit4/src/core/command/GdUnitShortcutAction.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcutAction.gd.uid b/addons/gdUnit4/src/core/command/GdUnitShortcutAction.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd new file mode 100644 index 00000000..766f9eab --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd new file mode 100644 index 00000000..6af8255a --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd b/addons/gdUnit4/src/core/event/GdUnitEvent.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd new file mode 100644 index 00000000..bd38eb2a --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd b/addons/gdUnit4/src/core/report/GdUnitReport.gd new file mode 100644 index 00000000..eb7ed2e5 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd @@ -0,0 +1,74 @@ +class_name GdUnitReport +extends Resource + +# report type +enum { + SUCCESS, + WARN, + FAILURE, + ORPHAN, + TERMINATED, + INTERUPTED, + ABORT, + SKIPPED, +} + +var _type :int +var _line_number :int +var _message :String + + +func create(p_type :int, p_line_number :int, p_message :String) -> GdUnitReport: + _type = p_type + _line_number = p_line_number + _message = p_message + return self + + +func type() -> int: + return _type + + +func line_number() -> int: + return _line_number + + +func message() -> String: + return _message + + +func is_skipped() -> bool: + return _type == SKIPPED + + +func is_warning() -> bool: + return _type == WARN + + +func is_failure() -> bool: + return _type == FAILURE + + +func is_error() -> bool: + return _type == TERMINATED or _type == INTERUPTED or _type == ABORT + + +func _to_string() -> String: + if _line_number == -1: + return "[color=green]line [/color][color=aqua]:[/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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd new file mode 100644 index 00000000..34dcfa38 --- /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 +## +## # Run specific test suite with ignored tests +## runtest -a -i +## [/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 ", + "Adds the given test suite or directory to the execution pipeline.", + TYPE_STRING + ), + CmdOption.new( + "-i, --ignore", + "-i ", + "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 ", + "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 ", + "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 ", "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 + runtest -a -i + """.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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd new file mode 100644 index 00000000..6551e085 --- /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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..e69de29b 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 00000000..6fc282db --- /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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd new file mode 100644 index 00000000..3f5090e1 --- /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: 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs new file mode 100644 index 00000000..096e83c7 --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2025 Mike Schulze +// MIT License - See LICENSE file in the repository root for full license text +#pragma warning disable IDE1006 +namespace gdUnit4.addons.gdUnit4.src.dotnet; +#pragma warning restore IDE1006 + +#if GDUNIT4NET_API_V5 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GdUnit4; +using GdUnit4.Api; + +using Godot; +using Godot.Collections; + +/// +/// The GdUnit4 GDScript - C# API wrapper. +/// +public partial class GdUnit4CSharpApi : RefCounted +{ + /// + /// The signal to be emitted when the execution is completed. + /// + [Signal] +#pragma warning disable CA1711 + public delegate void ExecutionCompletedEventHandler(); +#pragma warning restore CA1711 + +#pragma warning disable CA2213, SA1201 + private CancellationTokenSource? executionCts; +#pragma warning restore CA2213, SA1201 + + /// + /// Indicates if the API loaded. + /// + /// Returns true if the API already loaded. + public static bool IsApiLoaded() + => true; + + /// + /// Runs test discovery on the given script. + /// + /// The script to be scanned. + /// The list of tests discovered as dictionary. + public static Array DiscoverTests(CSharpScript sourceScript) + { + try + { + // Get the list of test case descriptors from the API + var testCaseDescriptors = GdUnit4NetApiGodotBridge.DiscoverTestsFromScript(sourceScript); + + // Convert each TestCaseDescriptor to a Dictionary + return testCaseDescriptors + .Select(descriptor => new Dictionary + { + ["guid"] = descriptor.Id.ToString(), + ["managed_type"] = descriptor.ManagedType, + ["test_name"] = descriptor.ManagedMethod, + ["source_file"] = sourceScript.ResourcePath, + ["line_number"] = descriptor.LineNumber, + ["attribute_index"] = descriptor.AttributeIndex, + ["require_godot_runtime"] = descriptor.RequireRunningGodotEngine, + ["code_file_path"] = descriptor.CodeFilePath ?? string.Empty, + ["simple_name"] = descriptor.SimpleName, + ["fully_qualified_name"] = descriptor.FullyQualifiedName, + ["assembly_location"] = descriptor.AssemblyPath + }) + .Aggregate( + new Array(), + (array, dict) => + { + array.Add(dict); + return array; + }); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error discovering tests: {e.Message}\n{e.StackTrace}"); +#pragma warning disable IDE0028 // Do not catch general exception types + return new Array(); +#pragma warning restore IDE0028 // Do not catch general exception types + } + } + + /// + /// Creates a test suite based on the specified source path and line number. + /// + /// The path to the source file from which to create the test suite. + /// The line number in the source file where the method to test is defined. + /// The path where the test suite should be created. + /// A dictionary containing information about the created test suite. + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => GdUnit4NetApiGodotBridge.CreateTestSuite(sourcePath, lineNumber, testSuitePath); + + /// + /// Gets the version of the GdUnit4 assembly. + /// + /// The version string of the GdUnit4 assembly. + public static string Version() + => GdUnit4NetApiGodotBridge.Version(); + + /// + public override void _Notification(int what) + { + if (what != NotificationPredelete) + return; + executionCts?.Dispose(); + executionCts = null; + } + + /// + /// Executes the tests and using the listener for reporting the results. + /// + /// A list of tests to be executed. + /// The listener to report the results. + public void ExecuteAsync(Array tests, Callable listener) + { + try + { + // Cancel any ongoing execution + executionCts?.Cancel(); + executionCts?.Dispose(); + + // Create new cancellation token source + executionCts = new CancellationTokenSource(); + + Debug.Assert(tests != null, nameof(tests) + " != null"); + var testSuiteNodes = new List { BuildTestSuiteNodeFrom(tests) }; + GdUnit4NetApiGodotBridge.ExecuteAsync(testSuiteNodes, listener, executionCts.Token) + .GetAwaiter() + .OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error executing tests: {e.Message}\n{e.StackTrace}"); + Task.Run(() => { }).GetAwaiter().OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } + } + + /// + /// Will cancel the current test execution. + /// + public void CancelExecution() + { + try + { + executionCts?.Cancel(); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error cancelling execution: {e.Message}"); + } + } + + // Convert a set of Tests stored as Dictionaries to TestSuiteNode + // all tests are assigned to a single test suit + internal static TestSuiteNode BuildTestSuiteNodeFrom(Array tests) + { + if (tests.Count == 0) + throw new InvalidOperationException("Cant build 'TestSuiteNode' from an empty test set."); + + // Create a suite ID + var suiteId = Guid.NewGuid(); + var firstTest = tests[0]; + var managedType = firstTest["managed_type"].AsString(); + var assemblyLocation = firstTest["assembly_location"].AsString(); + var sourceFile = firstTest["source_file"].AsString(); + + // Create TestCaseNodes for each test in the suite + var testCaseNodes = tests + .Select(test => new TestCaseNode + { + Id = Guid.Parse(test["guid"].AsString()), + ParentId = suiteId, + ManagedMethod = test["test_name"].AsString(), + LineNumber = test["line_number"].AsInt32(), + AttributeIndex = test["attribute_index"].AsInt32(), + RequireRunningGodotEngine = test["require_godot_runtime"].AsBool() + }) + .ToList(); + + return new TestSuiteNode + { + Id = suiteId, + ParentId = Guid.Empty, + ManagedType = managedType, + AssemblyPath = assemblyLocation, + SourceFile = sourceFile, + Tests = testCaseNodes + }; + } +} +#else +using Godot; +using Godot.Collections; + +public partial class GdUnit4CSharpApi : RefCounted +{ + [Signal] + public delegate void ExecutionCompletedEventHandler(); + + public static bool IsApiLoaded() + { + GD.PushWarning("No `gdunit4.api` dependency found, check your project dependencies."); + return false; + } + + + public static string Version() + => "Unknown"; + + public static Array DiscoverTests(CSharpScript sourceScript) => new(); + + public void ExecuteAsync(Array tests, Callable listener) + { + } + + public static bool IsTestSuite(CSharpScript script) + => false; + + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => new(); +} +#endif diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd new file mode 100644 index 00000000..3d8ba25f --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd @@ -0,0 +1,114 @@ +## GdUnit4CSharpApiLoader +## +## A bridge class that handles communication between GDScript and C# for the GdUnit4 testing framework. +## This loader acts as a compatibility layer to safely access the .NET API and ensure that calls +## only proceed when the .NET environment is properly configured and available. +## [br] +## The class handles: +## - Verification of .NET runtime availability +## - Loading the C# wrapper script +## - Checking for the GdUnit4Api assembly +## - Providing proxy methods to access GdUnit4 functionality in C# +@static_unload +class_name GdUnit4CSharpApiLoader +extends RefCounted + +## Cached reference to the loaded C# wrapper script +static var _gdUnit4NetWrapper: Script + +## Cached instance of the API (singleton pattern) +static var _api_instance: RefCounted + + +class TestEventListener extends RefCounted: + + func publish_event(event: Dictionary) -> void: + var test_event := GdUnitEvent.new().deserialize(event) + GdUnitSignals.instance().gdunit_event.emit(test_event) + +static var _test_event_listener := TestEventListener.new() + + +## Returns an instance of the GdUnit4CSharpApi wrapper.[br] +## @return Script: The loaded C# wrapper or null if .NET is not supported +static func instance() -> Script: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + + return _gdUnit4NetWrapper + + +## Returns or creates a single instance of the API [br] +## This improves performance by reusing the same object +static func api_instance() -> RefCounted: + if _api_instance == null and is_api_loaded(): + @warning_ignore("unsafe_method_access") + _api_instance = instance().new() + return _api_instance + + +static func is_engine_version_supported(engine_version: int = Engine.get_version_info().hex) -> bool: + return engine_version >= 0x40200 + + +## Checks if the .NET environment is properly configured and available.[br] +## @return bool: True if .NET is fully supported and the assembly is found +static func is_api_loaded() -> bool: + # If the wrapper is already loaded we don't need to check again + if _gdUnit4NetWrapper != null: + return true + + # First we check if this is a Godot .NET runtime instance + if not ClassDB.class_exists("CSharpScript") or not is_engine_version_supported(): + return false + # Second we check the C# project file exists + var assembly_name: String = ProjectSettings.get_setting("dotnet/project/assembly_name") + if assembly_name.is_empty() or not FileAccess.file_exists("res://%s.csproj" % assembly_name): + return false + + # Finally load the wrapper and check if the GdUnit4 assembly can be found + _gdUnit4NetWrapper = load("res://addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs") + @warning_ignore("unsafe_method_access") + return _gdUnit4NetWrapper.call("IsApiLoaded") + + +## Returns the version of the GdUnit4 .NET assembly.[br] +## @return String: The version string or "unknown" if .NET is not supported +static func version() -> String: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return "unknown" + @warning_ignore("unsafe_method_access") + return instance().Version() + + +static func discover_tests(source_script: Script) -> Array[GdUnitTestCase]: + var tests: Array = _gdUnit4NetWrapper.call("DiscoverTests", source_script) + + return Array(tests.map(GdUnitTestCase.from_dict), TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + +static func execute(tests: Array[GdUnitTestCase]) -> void: + var net_api := api_instance() + if net_api == null: + push_warning("Execute C# tests not supported!") + return + var tests_as_dict: Array[Dictionary] = Array(tests.map(GdUnitTestCase.to_dict), TYPE_DICTIONARY, "", null) + + net_api.call("ExecuteAsync", tests_as_dict, _test_event_listener.publish_event) + @warning_ignore("unsafe_property_access") + await net_api.ExecutionCompleted + + +static func create_test_suite(source_path: String, line_number: int, test_suite_path: String) -> GdUnitResult: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return GdUnitResult.error("Can't create test suite. No .NET support found.") + @warning_ignore("unsafe_method_access") + var result: Dictionary = instance().CreateTestSuite(source_path, line_number, test_suite_path) + if result.has("error"): + return GdUnitResult.error(str(result.get("error"))) + return GdUnitResult.success(result) + + +static func is_csharp_file(resource_path: String) -> bool: + var ext := resource_path.get_extension() + return ext == "cs" and GdUnit4CSharpApiLoader.is_api_loaded() diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd b/addons/gdUnit4/src/doubler/CallableDoubler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Fuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd new file mode 100644 index 00000000..277b4ac6 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd @@ -0,0 +1,112 @@ +## A fuzzer that generates random strings with configurable length and character sets.[br] +## +## It supports custom character sets defined by patterns or ranges, +## making it ideal for testing input validation, text processing, parsers, or any +## code that handles string data.[br] +## +## The fuzzer uses a pattern syntax to define allowed characters:[br] +## - Single characters: [code]abc[/code] allows 'a', 'b', 'c'[br] +## - Ranges: [code]a-z[/code] allows lowercase letters[br] +## - Special patterns: [code]\\w[/code] (word chars), [code]\\p{L}[/code] (letters), [code]\\p{N}[/code] (numbers)[br] +## +## [b]Usage example:[/b] +## [codeblock] +## # Test with alphanumeric strings +## func test_username(fuzzer := StringFuzzer.new(3, 20, "a-zA-Z0-9"), _fuzzer_iterations := 100): +## var username _= fuzzer.next_value() +## assert_bool(validate_username(username)).is_true() +## +## # Test with special characters +## func test_password(fuzzer := StringFuzzer.new(8, 32, "a-zA-Z0-9!@#$%"), _fuzzer_iterations := 100) -> void: +## var password := fuzzer.next_value() +## assert_str(password).has_length(8, Comparator.GREATER_EQUAL).has_length(32, Comparator.LESS_EQUAL) +## [/codeblock] +class_name StringFuzzer +extends Fuzzer + +## Default character set pattern including word characters, letters, numbers, and common symbols.[br] +## Includes: word characters (\\w), Unicode letters (\\p{L}), Unicode numbers (\\p{N}), +## and the characters: +, -, _, ' +const DEFAULT_CHARSET = "\\w\\p{L}\\p{N}+-_'" + +## Minimum length for generated strings (inclusive). +var _min_length: int +## Maximum length for generated strings (inclusive). +var _max_length: int +## Array of character codes that can be used in generated strings. +var _charset: PackedInt32Array + + +func _init(min_length: int, max_length: int, pattern: String = DEFAULT_CHARSET) -> void: + _min_length = min_length + _max_length = max_length + 1 # +1 for inclusive + assert(not null or not pattern.is_empty()) + assert(_min_length > 0 and _min_length < _max_length) + _charset = _extract_charset(pattern) + + +## Generates a random string based on configured parameters.[br] +## +## Creates a string with random length between [member _min_length] and +## [member _max_length], using only characters from the configured charset. +## Each character is selected randomly and independently.[br] +## +## [b]Example:[/b] +## [codeblock] +## var fuzzer = StringFuzzer.new(5, 10, "ABC") +## for i in range(5): +## var str = fuzzer.next_value() +## print("Generated: ", str) +## # Possible outputs: "ABCAB", "BCAABCA", "CCCBAA", etc. +## assert(str.length() >= 5 and str.length() <= 10) +## for c in str: +## assert(c in ["A", "B", "C"]) +## [/codeblock] +## +## @returns A random string matching the configured constraints. +func next_value() -> String: + var value := PackedInt32Array() + var max_char := len(_charset) + var length: int = max(_min_length, randi() % _max_length) + for i in length: + @warning_ignore("return_value_discarded") + value.append(_charset[randi() % max_char]) + return value.to_byte_array().get_string_from_utf32() + + +static func _extract_charset(pattern: String) -> PackedInt32Array: + var reg := RegEx.new() + if reg.compile(pattern) != OK: + push_error("Invalid pattern to generate Strings! Use e.g '\\w\\p{L}\\p{N}+-_'") + return PackedInt32Array() + + var charset := PackedInt32Array() + var char_before := -1 + var index := 0 + while index < pattern.length(): + var char_current := pattern.unicode_at(index) + # - range token at first or last pos? + if char_current == 45 and (index == 0 or index == pattern.length()-1): + charset.append(char_current) + index += 1 + continue + index += 1 + # range starts + if char_current == 45 and char_before != -1: + var char_next := pattern.unicode_at(index) + var characters := _build_chars(char_before, char_next) + for character in characters: + charset.append(character) + char_before = -1 + index += 1 + continue + char_before = char_current + charset.append(char_current) + return charset + + +static func _build_chars(from: int, to: int) -> PackedInt32Array: + var characters := PackedInt32Array() + for character in range(from+1, to+1): + characters.append(character) + return characters diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd b/addons/gdUnit4/src/mocking/GdUnitMock.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd new file mode 100644 index 00000000..537e9239 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd @@ -0,0 +1,186 @@ +class_name GdUnitMockBuilder +extends GdUnitClassDoubler + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const MOCK_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/mocking/GdUnitMockImpl.gd") + + +static func is_push_errors() -> bool: + return GdUnitSettings.is_report_push_errors() + + +static func build(clazz :Variant, mock_mode :String, debug_write := false) -> Variant: + var push_errors := is_push_errors() + if not is_mockable(clazz, push_errors): + return null + # mocking a scene? + if GdObjects.is_scene(clazz): + var packed_scene: PackedScene = clazz + return mock_on_scene(packed_scene, debug_write) + elif typeof(clazz) == TYPE_STRING and str(clazz).ends_with(".tscn"): + var packed_scene: PackedScene = load(str(clazz)) + return mock_on_scene(packed_scene, debug_write) + # mocking a script + var instance := create_instance(clazz) + if instance == null: + push_error("Can't create instance of class %s" % clazz) + var mock := mock_on_script(instance, clazz, [ "get_script"], debug_write) + if not instance is RefCounted: + instance.free() + if mock == null: + return null + var mock_instance: Object = mock.new() + @warning_ignore("unsafe_method_access") + mock_instance.__init(mock, mock_mode) + return register_auto_free(mock_instance) + + +static func create_instance(clazz: Variant) -> Object: + match typeof(clazz): + TYPE_OBJECT: + var obj: Object = clazz + if clazz is GDScript: + var script: GDScript = clazz + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif obj.is_class("GDScriptNativeClass"): + @warning_ignore("unsafe_method_access") + return obj.new() + TYPE_STRING: + var clazz_name: String = clazz + if clazz_name.ends_with(".gd"): + var script: GDScript = load(clazz_name) + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif ClassDB.can_instantiate(clazz_name): + return ClassDB.instantiate(clazz_name) + + push_error("Can't create a mock validation instance from class: `%s`" % clazz) + return null + + +static func mock_on_scene(scene: PackedScene, debug_write: bool) -> Variant: + var push_errors := is_push_errors() + if not scene.can_instantiate(): + if push_errors: + push_error("Can't instanciate scene '%s'" % scene.resource_path) + return null + var scene_instance := scene.instantiate() + # we can only mock checked a scene with attached script + var scene_script: Script = scene_instance.get_script() + if scene_script == null: + if push_errors: + push_error("Can't create a mockable instance for a scene without script '%s'" % scene.resource_path) + @warning_ignore("return_value_discarded") + GdUnitTools.free_instance(scene_instance) + return null + + var script_path := scene_script.get_path() + var mock := mock_on_script(scene_instance, script_path, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write) + if mock == null: + return null + scene_instance.set_script(mock) + @warning_ignore("unsafe_method_access") + scene_instance.__init(mock, GdUnitMock.CALL_REAL_FUNC) + return register_auto_free(scene_instance) + + +static func get_class_info(clazz :Variant) -> Dictionary: + var clazz_name :String = GdObjects.extract_class_name(clazz).value() + var clazz_path := GdObjects.extract_class_path(clazz) + return { + "class_name" : clazz_name, + "class_path" : clazz_path + } + + +static func mock_on_script(instance :Object, clazz :Variant, function_excludes :PackedStringArray, debug_write :bool) -> GDScript: + var function_doubler := GdUnitMockFunctionDoubler.new() + var class_info := get_class_info(clazz) + var clazz_name :String = class_info.get("class_name") + var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name]) + var mock_template := MOCK_TEMPLATE.source_code.format({ + "instance_id" : abs(instance.get_instance_id()), + "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0] + }) + var lines := load_template(mock_template, class_info) + lines += double_functions(instance, clazz_name, clazz_path, function_doubler, function_excludes) + # We disable warning/errors for inferred_declaration + if Engine.get_version_info().hex >= 0x40400: + lines.insert(0, '@warning_ignore_start("inferred_declaration")') + lines.append('@warning_ignore_restore("inferred_declaration")') + + var mock := GDScript.new() + mock.source_code = "\n".join(lines) + mock.resource_name = "Mock%s_%d.gd" % [clazz_name, Time.get_ticks_msec()] + mock.resource_path = "%s/%s" % [GdUnitFileAccess.create_temp_dir("mock"), mock.resource_name] + + if debug_write: + @warning_ignore("return_value_discarded") + DirAccess.remove_absolute(mock.resource_path) + @warning_ignore("return_value_discarded") + ResourceSaver.save(mock, mock.resource_path) + var error := mock.reload(true) + if error != OK: + push_error("Critical!!!, MockBuilder error, please contact the developer.") + return null + return mock + + +static func is_mockable(clazz :Variant, push_errors :bool=false) -> bool: + var clazz_type := typeof(clazz) + if clazz_type != TYPE_OBJECT and clazz_type != TYPE_STRING: + push_error("Invalid clazz type is used") + return false + # is PackedScene + if GdObjects.is_scene(clazz): + return true + if GdObjects.is_native_class(clazz): + return true + # verify class type + if GdObjects.is_object(clazz): + if GdObjects.is_instance(clazz): + if push_errors: + push_error("It is not allowed to mock an instance '%s', use class name instead, Read 'Mocker' documentation for details" % clazz) + return false + + if not GdObjects.can_be_instantiate(clazz): + if push_errors: + push_error("Can't create a mockable instance for class '%s'" % clazz) + return false + return true + # verify by class name checked registered classes + var clazz_name: String = clazz + if ClassDB.class_exists(clazz_name): + if Engine.has_singleton(clazz_name): + if push_errors: + push_error("Mocking a singelton class '%s' is not allowed! Read 'Mocker' documentation for details" % clazz_name) + return false + if not ClassDB.can_instantiate(clazz_name): + if push_errors: + push_error("Mocking class '%s' is not allowed it cannot be instantiated!" % clazz_name) + return false + # exclude classes where name starts with a underscore + if clazz_name.find("_") == 0: + if push_errors: + push_error("Can't create a mockable instance for protected class '%s'" % clazz_name) + return false + return true + # at least try to load as a script + var clazz_path := clazz_name + if not FileAccess.file_exists(clazz_path): + if push_errors: + push_error("'%s' cannot be mocked for the specified resource path, the resource does not exist" % clazz_name) + return false + # finally verify is a script resource + var resource := load(clazz_path) + if resource == null: + if push_errors: + push_error("'%s' cannot be mocked the script cannot be loaded." % clazz_name) + return false + # finally check is extending from script + return GdObjects.is_script(resource) or GdObjects.is_scene(resource) + + +static func register_auto_free(obj :Variant) -> Variant: + return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj) diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd new file mode 100644 index 00000000..774ca3e3 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd @@ -0,0 +1,143 @@ +class_name DoubledMockClassSourceClassName + +################################################################################ +# internal mocking stuff +################################################################################ + +const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}" + + +class GdUnitMockDoublerState: + const __SOURCE_CLASS := "{gdunit_source_class}" + + var excluded_methods := PackedStringArray() + var working_mode := GdUnitMock.RETURN_DEFAULTS + var is_prepare_return := false + var return_values := Dictionary() + var return_value: Variant = null + + + func _init(working_mode_ := GdUnitMock.RETURN_DEFAULTS) -> void: + working_mode = working_mode_ + + +var __mock_state := GdUnitMockDoublerState.new() +@warning_ignore("unused_private_class_variable") +var __verifier_instance := GdUnitObjectInteractionsVerifier.new() + + +func __init(__script: GDScript, mock_working_mode: String) -> void: + super.set_script(__script) + __init_doubler() + __mock_state.working_mode = mock_working_mode + + +static func __doubler_state() -> GdUnitMockDoublerState: + if Engine.has_meta(__INSTANCE_ID): + return Engine.get_meta(__INSTANCE_ID).__mock_state + return null + + +func __init_doubler() -> void: + Engine.set_meta(__INSTANCE_ID, self) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID): + Engine.remove_meta(__INSTANCE_ID) + + +static func __get_verifier() -> GdUnitObjectInteractionsVerifier: + return Engine.get_meta(__INSTANCE_ID).__verifier_instance + + +static func __is_prepare_return_value() -> bool: + return __doubler_state().is_prepare_return + + +static func __sort_by_argument_matcher(__left_args: Array, __right_args: Array) -> bool: + for __index in __left_args.size(): + var __larg: Variant = __left_args[__index] + if __larg is GdUnitArgumentMatcher: + return false + return true + + +# we need to sort by matcher arguments so that they are all at the end of the list +static func __sort_dictionary(__unsorted_args: Dictionary) -> Dictionary: + # only need to sort if contains more than one entry + if __unsorted_args.size() <= 1: + return __unsorted_args + var __sorted_args: Array = __unsorted_args.keys() + __sorted_args.sort_custom(__sort_by_argument_matcher) + var __sorted_result := {} + for __index in __sorted_args.size(): + var key :Variant = __sorted_args[__index] + __sorted_result[key] = __unsorted_args[key] + return __sorted_result + + +static func __save_function_return_value(__func_name: String, __func_args: Array) -> void: + var doubler_state := __doubler_state() + var mocked_return_value_by_args: Dictionary = doubler_state.return_values.get(__func_name, {}) + + mocked_return_value_by_args[__func_args] = doubler_state.return_value + doubler_state.return_values[__func_name] = __sort_dictionary(mocked_return_value_by_args) + doubler_state.return_value = null + doubler_state.is_prepare_return = false + + +static func __is_mocked_args_match(__func_args: Array, __mocked_args: Array) -> bool: + var __is_matching := false + for __index in __mocked_args.size(): + var __fuction_args: Array = __mocked_args[__index] + if __func_args.size() != __fuction_args.size(): + continue + __is_matching = true + for __arg_index in __func_args.size(): + var __func_arg: Variant = __func_args[__arg_index] + var __mock_arg: Variant = __fuction_args[__arg_index] + if __mock_arg is GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + __is_matching = __is_matching and __mock_arg.is_match(__func_arg) + else: + __is_matching = __is_matching and typeof(__func_arg) == typeof(__mock_arg) and __func_arg == __mock_arg + if not __is_matching: + break + if __is_matching: + break + return __is_matching + + +static func __return_mock_value(__func_name: String, __func_args: Array, __default_return_value: Variant) -> Variant: + var doubler_state := __doubler_state() + if not doubler_state.return_values.has(__func_name): + return __default_return_value + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + for __index in __mocked_args.size(): + var __margs: Variant = __mocked_args[__index] + if __is_mocked_args_match(__func_args, [__margs]): + return doubler_state.return_values[__func_name][__margs] + return __default_return_value + + +static func __is_do_not_call_real_func(__func_name: String, __func_args := []) -> bool: + var doubler_state := __doubler_state() + var __is_call_real_func: bool = doubler_state.working_mode == GdUnitMock.CALL_REAL_FUNC and not doubler_state.excluded_methods.has(__func_name) + # do not call real funcions for mocked functions + if __is_call_real_func and doubler_state.return_values.has(__func_name): + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + return __is_mocked_args_match(__func_args, __mocked_args) + return !__is_call_real_func + + +func __exclude_method_call(exluded_methods: PackedStringArray) -> void: + __doubler_state().excluded_methods.append_array(exluded_methods) + + +func __do_return(mock_do_return_value: Variant) -> Object: + __doubler_state().return_value = mock_do_return_value + __doubler_state().is_prepare_return = true + return self diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd b/addons/gdUnit4/src/network/GdUnitServer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd.uid b/addons/gdUnit4/src/network/GdUnitServer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitServer.tscn b/addons/gdUnit4/src/network/GdUnitServer.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd b/addons/gdUnit4/src/network/GdUnitServerConstants.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd b/addons/gdUnit4/src/network/GdUnitTask.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd.uid b/addons/gdUnit4/src/network/GdUnitTask.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd b/addons/gdUnit4/src/network/GdUnitTcpClient.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd b/addons/gdUnit4/src/network/GdUnitTcpNode.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd b/addons/gdUnit4/src/network/GdUnitTcpServer.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd b/addons/gdUnit4/src/network/rpc/RPC.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd.uid b/addons/gdUnit4/src/network/rpc/RPC.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd new file mode 100644 index 00000000..6b494cfe --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd @@ -0,0 +1,13 @@ +class_name RPCClientConnect +extends RPC + +var _client_id: int + + +func with_id(id: int) -> RPCClientConnect: + _client_id = id + return self + + +func client_id() -> int: + return _client_id diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd b/addons/gdUnit4/src/network/rpc/RPCMessage.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/.gdignore b/addons/gdUnit4/src/reporters/html/template/.gdignore new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/css/logo.png b/addons/gdUnit4/src/reporters/html/template/css/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/css/styles.css b/addons/gdUnit4/src/reporters/html/template/css/styles.css new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/folder_report.html b/addons/gdUnit4/src/reporters/html/template/folder_report.html new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/index.html b/addons/gdUnit4/src/reporters/html/template/index.html new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/html/template/suite_report.html b/addons/gdUnit4/src/reporters/html/template/suite_report.html new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd b/addons/gdUnit4/src/reporters/xml/XmlElement.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd b/addons/gdUnit4/src/ui/GdUnitConsole.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.tscn b/addons/gdUnit4/src/ui/GdUnitConsole.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd b/addons/gdUnit4/src/ui/GdUnitFonts.gd new file mode 100644 index 00000000..0cb0f5d5 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd @@ -0,0 +1,36 @@ +@tool +class_name GdUnitFonts +extends RefCounted + + +static func init_fonts(item: CanvasItem) -> float: + # set default size + item.set("theme_override_font_sizes/font_size", 16) + + if Engine.is_editor_hint(): + var base_control := EditorInterface.get_base_control() + # source modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs + # https://github.com/godotengine/godot/blob/9ee1873ae1e09c217ac24a5800007f63cb895615/editor/editor_log.cpp#L65 + var output_source_mono := base_control.get_theme_font("output_source_mono", "EditorFonts") + var output_source_bold_italic := base_control.get_theme_font("output_source_bold_italic", "EditorFonts") + var output_source_italic := base_control.get_theme_font("output_source_italic", "EditorFonts") + var output_source_bold := base_control.get_theme_font("output_source_bold", "EditorFonts") + var output_source := base_control.get_theme_font("output_source", "EditorFonts") + var settings := EditorInterface.get_editor_settings() + var scale_factor := EditorInterface.get_editor_scale() + var font_size: float = settings.get_setting("interface/editor/main_font_size") + + font_size *= scale_factor + item.set("theme_override_fonts/normal_font", output_source) + item.set("theme_override_fonts/bold_font", output_source_bold) + item.set("theme_override_fonts/italics_font", output_source_italic) + item.set("theme_override_fonts/bold_italics_font", output_source_bold_italic) + item.set("theme_override_fonts/mono_font", output_source_mono) + item.set("theme_override_font_sizes/font_size", font_size) + item.set("theme_override_font_sizes/normal_font_size", font_size) + item.set("theme_override_font_sizes/bold_font_size", font_size) + item.set("theme_override_font_sizes/italics_font_size", font_size) + item.set("theme_override_font_sizes/bold_italics_font_size", font_size) + item.set("theme_override_font_sizes/mono_font_size", font_size) + return font_size + return 16.0 diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd b/addons/gdUnit4/src/ui/GdUnitInspector.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.tscn b/addons/gdUnit4/src/ui/GdUnitInspector.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd b/addons/gdUnit4/src/ui/GdUnitUiTools.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/ScriptEditorControls.gd b/addons/gdUnit4/src/ui/ScriptEditorControls.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid b/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandlerV44.gdx b/addons/gdUnit4/src/ui/menu/EditorFileSystemContextMenuHandlerV44.gdx new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandler.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandler.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandlerV44.gdx b/addons/gdUnit4/src/ui/menu/ScriptEditorContextMenuHandlerV44.gdx new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn b/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd new file mode 100644 index 00000000..536fe87f --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd @@ -0,0 +1,1245 @@ +@tool +extends VSplitContainer + +## Will be emitted when the test index counter is changed +signal test_counters_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE) +signal tree_item_selected(item: TreeItem) + + +const CONTEXT_MENU_RUN_ID = 0 +const CONTEXT_MENU_DEBUG_ID = 1 +const CONTEXT_MENU_COLLAPSE_ALL = 3 +const CONTEXT_MENU_EXPAND_ALL = 4 + + +@onready var _tree: Tree = $Panel/Tree +@onready var _report_list: Node = $report/ScrollContainer/list +@onready var _report_template: RichTextLabel = $report/report_template +@onready var _context_menu: PopupMenu = $contextMenu +@onready var _discover_hint: Control = %discover_hint +@onready var _spinner: Button = %spinner + +# loading tree icons +@onready var ICON_SPINNER := GdUnitUiTools.get_spinner() +@onready var ICON_FOLDER := GdUnitUiTools.get_icon("Folder") +# gdscript icons +@onready var ICON_GDSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("GDScript", Color.LIGHT_GRAY) +@onready var ICON_GDSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_GDScript_icon("StatusSuccess", Color.DARK_GREEN) +@onready var ICON_GDSCRIPT_TEST_FLAKY := GdUnitUiTools.get_GDScript_icon("CheckBox", Color.GREEN_YELLOW) +@onready var ICON_GDSCRIPT_TEST_FAILED := GdUnitUiTools.get_GDScript_icon("StatusError", Color.SKY_BLUE) +@onready var ICON_GDSCRIPT_TEST_ERROR := GdUnitUiTools.get_GDScript_icon("StatusError", Color.DARK_RED) +@onready var ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_GREEN) +@onready var ICON_GDSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.SKY_BLUE) +@onready var ICON_GDSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_RED) +# csharp script icons +@onready var ICON_CSSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("CSharpScript", Color.LIGHT_GRAY) +@onready var ICON_CSSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_CSharpScript_icon("StatusSuccess", Color.DARK_GREEN) +@onready var ICON_CSSCRIPT_TEST_FAILED := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.SKY_BLUE) +@onready var ICON_CSSCRIPT_TEST_ERROR := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.DARK_RED) +@onready var ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_GREEN) +@onready var ICON_CSSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.SKY_BLUE) +@onready var ICON_CSSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_RED) + + +enum GdUnitType { + FOLDER, + TEST_SUITE, + TEST_CASE, + TEST_GROUP +} + +const META_GDUNIT_PROGRESS_COUNT_MAX := "gdUnit_progress_count_max" +const META_GDUNIT_PROGRESS_INDEX := "gdUnit_progress_index" +const META_TEST_CASE := "gdunit_test_case" +const META_GDUNIT_NAME := "gdUnit_name" +const META_GDUNIT_STATE := "gdUnit_state" +const META_GDUNIT_TYPE := "gdUnit_type" +const META_GDUNIT_SUCCESS_TESTS := "gdUnit_suite_success_tests" +const META_GDUNIT_REPORT := "gdUnit_report" +const META_GDUNIT_ORPHAN := "gdUnit_orphan" +const META_GDUNIT_EXECUTION_TIME := "gdUnit_execution_time" +const META_GDUNIT_ORIGINAL_INDEX = "gdunit_original_index" +const STATE = GdUnitInspectorTreeConstants.STATE + + +var _tree_root: TreeItem +var _current_selected_item: TreeItem = null +var _current_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode() +var _run_test_recovery := true + + +## Used for debugging purposes only +func print_tree_item_ids(parent: TreeItem) -> TreeItem: + for child in parent.get_children(): + if child.has_meta(META_TEST_CASE): + var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE) + prints(test_case.guid, test_case.test_name) + + if child.get_child_count() > 0: + print_tree_item_ids(child) + + return null + + +func _find_tree_item(parent: TreeItem, item_name: String) -> TreeItem: + for child in parent.get_children(): + if child.get_meta(META_GDUNIT_NAME) == item_name: + return child + return null + + +func _find_tree_item_by_id(parent: TreeItem, id: GdUnitGUID) -> TreeItem: + for child in parent.get_children(): + if is_test_id(child, id): + return child + if child.get_child_count() > 0: + var item := _find_tree_item_by_id(child, id) + if item != null: + return item + + return null + + +func _find_tree_item_by_test_suite(parent: TreeItem, suite_path: String, suite_name: String) -> TreeItem: + for child in parent.get_children(): + if child.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE: + var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE) + if test_case.suite_resource_path == suite_path and test_case.suite_name == suite_name: + return child + if child.get_child_count() > 0: + var item := _find_tree_item_by_test_suite(child, suite_path, suite_name) + if item != null: + return item + return null + + +func _find_first_item_by_state(parent: TreeItem, item_state: STATE, reverse := false) -> TreeItem: + var itmes := parent.get_children() + if reverse: + itmes.reverse() + for item in itmes: + if is_test_case(item) and (is_item_state(item, item_state)): + return item + var failure_item := _find_first_item_by_state(item, item_state, reverse) + if failure_item != null: + return failure_item + return null + + +func _find_last_item_by_state(parent: TreeItem, item_state: STATE) -> TreeItem: + return _find_first_item_by_state(parent, item_state, true) + + +func _find_item_by_state(current: TreeItem, item_state: STATE, prev := false) -> TreeItem: + var next := current.get_prev_in_tree() if prev else current.get_next_in_tree() + if next == null or next == _tree_root: + return null + if is_test_case(next) and is_item_state(next, item_state): + return next + return _find_item_by_state(next, item_state, prev) + + +func is_item_state(item: TreeItem, item_state: STATE) -> bool: + return item.has_meta(META_GDUNIT_STATE) and item.get_meta(META_GDUNIT_STATE) == item_state + + +func is_state_running(item: TreeItem) -> bool: + return is_item_state(item, STATE.RUNNING) + + +func is_state_success(item: TreeItem) -> bool: + return is_item_state(item, STATE.SUCCESS) + + +func is_state_warning(item: TreeItem) -> bool: + return is_item_state(item, STATE.WARNING) + + +func is_state_failed(item: TreeItem) -> bool: + return is_item_state(item, STATE.FAILED) + + +func is_state_error(item: TreeItem) -> bool: + return is_item_state(item, STATE.ERROR) or is_item_state(item, STATE.ABORDED) + + +func is_item_state_orphan(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_ORPHAN) + + +func is_test_suite(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE + + +func is_test_case(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_CASE + + +func is_folder(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER + + +func is_test_id(item: TreeItem, id: GdUnitGUID) -> bool: + if not item.has_meta(META_TEST_CASE): + return false + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.guid.equals(id) + + +func disable_test_recovery() -> void: + _run_test_recovery = false + + +@warning_ignore("return_value_discarded") +func _ready() -> void: + _context_menu.set_item_icon(CONTEXT_MENU_RUN_ID, GdUnitUiTools.get_icon("Play")) + _context_menu.set_item_icon(CONTEXT_MENU_DEBUG_ID, GdUnitUiTools.get_icon("PlayStart")) + _context_menu.set_item_icon(CONTEXT_MENU_EXPAND_ALL, GdUnitUiTools.get_icon("ExpandTree")) + _context_menu.set_item_icon(CONTEXT_MENU_COLLAPSE_ALL, GdUnitUiTools.get_icon("CollapseTree")) + # do colorize the icons + #for index in _context_menu.item_count: + # _context_menu.set_item_icon_modulate(index, Color.MEDIUM_PURPLE) + + _spinner.icon = GdUnitUiTools.get_spinner() + init_tree() + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + GdUnitSignals.instance().gdunit_test_discover_added.connect(on_test_case_discover_added) + GdUnitSignals.instance().gdunit_test_discover_deleted.connect(on_test_case_discover_deleted) + GdUnitSignals.instance().gdunit_test_discover_modified.connect(on_test_case_discover_modified) + var command_handler := GdUnitCommandHandler.instance() + command_handler.gdunit_runner_stop.connect(_on_gdunit_runner_stop) + if _run_test_recovery: + GdUnitTestDiscoverer.restore_last_session() + + +# we need current to manually redraw bacause of the animation bug +# https://github.com/godotengine/godot/issues/69330 +func _process(_delta: float) -> void: + if is_visible_in_tree(): + queue_redraw() + + +func init_tree() -> void: + cleanup_tree() + _tree.deselect_all() + _tree.set_hide_root(true) + _tree.ensure_cursor_is_visible() + _tree.set_allow_reselect(true) + _tree.set_allow_rmb_select(true) + _tree.set_columns(2) + _tree.set_column_clip_content(0, true) + _tree.set_column_expand_ratio(0, 1) + _tree.set_column_custom_minimum_width(0, 240) + _tree.set_column_expand_ratio(1, 0) + _tree.set_column_custom_minimum_width(1, 100) + _tree_root = _tree.create_item() + _tree_root.set_text(0, "tree_root") + _tree_root.set_meta(META_GDUNIT_NAME, "tree_root") + _tree_root.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + _tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL) + _tree_root.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + # fix tree icon scaling + var scale_factor := EditorInterface.get_editor_scale() if Engine.is_editor_hint() else 1.0 + _tree.set("theme_override_constants/icon_max_width", 16 * scale_factor) + + +func cleanup_tree() -> void: + clear_reports() + if not _tree_root: + return + _free_recursive() + _tree.clear() + _current_selected_item = null + + +func _free_recursive(items:=_tree_root.get_children()) -> void: + for item in items: + _free_recursive(item.get_children()) + item.call_deferred("free") + + +func sort_tree_items(parent: TreeItem) -> void: + _sort_tree_items(parent, GdUnitSettings.get_inspector_tree_sort_mode()) + _tree.queue_redraw() + + +static func _sort_tree_items(parent: TreeItem, sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void: + parent.visible = false + var items := parent.get_children() + # first remove all childs before sorting + for item in items: + parent.remove_child(item) + + # do sort by selected sort mode + match sort_mode: + GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED: + items.sort_custom(sort_items_by_original_index) + + GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING: + items.sort_custom(sort_items_by_name.bind(true)) + + GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING: + items.sort_custom(sort_items_by_name.bind(false)) + + GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME: + items.sort_custom(sort_items_by_execution_time) + + # readding sorted childs + for item in items: + parent.add_child(item) + if item.get_child_count() > 0: + _sort_tree_items(item, sort_mode) + parent.visible = true + + +static func sort_items_by_name(a: TreeItem, b: TreeItem, ascending: bool) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + # sort by name + var name_a: String = a.get_meta(META_GDUNIT_NAME) + var name_b: String = b.get_meta(META_GDUNIT_NAME) + var comparison := name_a.naturalnocasecmp_to(name_b) + + return comparison < 0 if ascending else comparison > 0 + + +static func sort_items_by_execution_time(a: TreeItem, b: TreeItem) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + var execution_time_a :int = a.get_meta(META_GDUNIT_EXECUTION_TIME) + var execution_time_b :int = b.get_meta(META_GDUNIT_EXECUTION_TIME) + # if has same execution time sort by name + if execution_time_a == execution_time_b: + var name_a :String = a.get_meta(META_GDUNIT_NAME) + var name_b :String = b.get_meta(META_GDUNIT_NAME) + return name_a.naturalnocasecmp_to(name_b) > 0 + return execution_time_a > execution_time_b + + +static func sort_items_by_original_index(a: TreeItem, b: TreeItem) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + var index_a :int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX) + var index_b :int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX) + + # Sorting by index + return index_a < index_b + + +func restructure_tree(parent: TreeItem, tree_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void: + _current_tree_view_mode = tree_mode + + match tree_mode: + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT: + restructure_tree_to_flat(parent) + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE: + restructure_tree_to_tree(parent) + recalculate_counters(_tree_root) + # finally apply actual sort mode + sort_tree_items(_tree_root) + + +# Restructure into flat mode +func restructure_tree_to_flat(parent: TreeItem) -> void: + var folders := flatmap_folders(parent) + # Store current folder paths and their test suites + for folder_path: String in folders: + var test_suites: Array[TreeItem] = folders[folder_path] + if test_suites.is_empty(): + continue + + # Create flat folder and move test suites into it + var folder := _tree.create_item(parent) + folder.set_meta(META_GDUNIT_NAME, folder_path) + update_item_total_counter(folder) + set_state_initial(folder, GdUnitType.FOLDER) + + # Move test suites under the flat folder + for test_suite in test_suites: + var old_parent := test_suite.get_parent() + old_parent.remove_child(test_suite) + folder.add_child(test_suite) + + # Cleanup old folder structure + cleanup_empty_folders(parent) + + +# Restructure into hierarchical tree mode +func restructure_tree_to_tree(parent: TreeItem) -> void: + var items_to_process := parent.get_children().duplicate() + + for item: TreeItem in items_to_process: + if is_folder(item): + var folder_path: String = item.get_meta(META_GDUNIT_NAME) + var parts := folder_path.split("/") + + if parts.size() > 1: + var current_parent := parent + # Build folder hierarchy + for part in parts: + var next := _find_tree_item(current_parent, part) + if not next: + next = _tree.create_item(current_parent) + next.set_meta(META_GDUNIT_NAME, part) + set_state_initial(next, GdUnitType.FOLDER) + current_parent = next + + # Move test suites to deepest folder + var test_suites := item.get_children() + for test_suite in test_suites: + item.remove_child(test_suite) + current_parent.add_child(test_suite) + + # Remove the flat folder + item.get_parent().remove_child(item) + item.free() + + +func flatmap_folders(parent: TreeItem) -> Dictionary: + var folder_map := {} + + for item in parent.get_children(): + if is_folder(item): + var current_path: String = item.get_meta(META_GDUNIT_NAME) + # Get parent folder paths + var parent_path := get_parent_folder_path(item) + if parent_path: + current_path = parent_path + "/" + current_path + + # Collect direct children of this folder + var children: Array[TreeItem] = [] + for child in item.get_children(): + if is_test_suite(child): + children.append(child) + + # Add children to existing path or create new entry + if not children.is_empty(): + if folder_map.has(current_path): + @warning_ignore("unsafe_method_access") + folder_map[current_path].append_array(children) + else: + folder_map[current_path] = children + + # Recursively process subfolders + var sub_folders := flatmap_folders(item) + for path: String in sub_folders.keys(): + if folder_map.has(path): + @warning_ignore("unsafe_method_access") + folder_map[path].append_array(sub_folders[path]) + else: + folder_map[path] = sub_folders[path] + return folder_map + + +func get_parent_folder_path(item: TreeItem) -> String: + var path := "" + var parent := item.get_parent() + + while parent != _tree_root: + if is_folder(parent): + path = parent.get_meta(META_GDUNIT_NAME) + ("/" + path if path else "") + parent = parent.get_parent() + + return path + + +func cleanup_empty_folders(parent: TreeItem) -> void: + var folders: Array[TreeItem] = [] + # First collect all folders to avoid modification during iteration + for item in parent.get_children(): + if is_folder(item): + folders.append(item) + + # Process collected folders + for folder in folders: + cleanup_empty_folders(folder) + # Remove folder if it has no children after cleanup + if folder.get_child_count() == 0: + parent.remove_child(folder) + folder.free() + + +func reset_tree_state(parent: TreeItem) -> void: + if parent == _tree_root: + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + _tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL) + test_counters_changed.emit(0, 0, STATE.INITIAL) + + for item in parent.get_children(): + set_state_initial(item, get_item_type(item)) + reset_tree_state(item) + + +func select_item(item: TreeItem) -> TreeItem: + if item != null: + # enshure the parent is collapsed + do_collapse_parent(item) + item.select(0) + _tree.ensure_cursor_is_visible() + _tree.scroll_to_item(item, true) + return item + + +func do_collapse_parent(item: TreeItem) -> void: + if item != null: + item.collapsed = false + do_collapse_parent(item.get_parent()) + + +func do_collapse_all(collapse: bool, parent := _tree_root) -> void: + for item in parent.get_children(): + item.collapsed = collapse + if not collapse: + do_collapse_all(collapse, item) + + +func set_state_initial(item: TreeItem, type: GdUnitType) -> void: + item.set_text(0, str(item.get_meta(META_GDUNIT_NAME))) + item.set_custom_color(0, Color.LIGHT_GRAY) + item.set_tooltip_text(0, "") + item.set_text_overrun_behavior(0, TextServer.OVERRUN_TRIM_CHAR) + item.set_expand_right(0, true) + + item.set_custom_color(1, Color.LIGHT_GRAY) + item.set_text(1, "") + item.set_expand_right(1, true) + item.set_tooltip_text(1, "") + + item.set_meta(META_GDUNIT_STATE, STATE.INITIAL) + item.set_meta(META_GDUNIT_TYPE, type) + item.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + item.set_meta(META_GDUNIT_EXECUTION_TIME, 0) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) and item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) > 0: + item.set_text(0, "(0/%d) %s" % [item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]) + item.remove_meta(META_GDUNIT_REPORT) + item.remove_meta(META_GDUNIT_ORPHAN) + + set_item_icon_by_state(item) + + +func set_state_running(item: TreeItem, is_running: bool) -> void: + if is_state_running(item): + return + if is_item_state(item, STATE.INITIAL): + item.set_custom_color(0, Color.DARK_GREEN) + item.set_custom_color(1, Color.DARK_GREEN) + item.set_meta(META_GDUNIT_STATE, STATE.RUNNING) + item.collapsed = false + + if is_running: + item.set_icon(0, ICON_SPINNER) + else: + set_item_icon_by_state(item) + for child in item.get_children(): + set_item_icon_by_state(child) + + var parent := item.get_parent() + if parent != _tree_root: + set_state_running(parent, is_running) + + +func set_state_succeded(item: TreeItem) -> void: + # Do not overwrite higher states + if is_state_error(item) or is_state_failed(item): + return + if item == _tree_root: + return + item.set_custom_color(0, Color.GREEN) + item.set_custom_color(1, Color.GREEN) + item.set_meta(META_GDUNIT_STATE, STATE.SUCCESS) + item.collapsed = GdUnitSettings.is_inspector_node_collapse() + set_item_icon_by_state(item) + + +func set_state_flaky(item: TreeItem, event: GdUnitEvent) -> void: + # Do not overwrite higher states + if is_state_error(item): + return + var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT) + item.set_meta(META_GDUNIT_STATE, STATE.FLAKY) + if retry_count > 1: + var item_text: String = item.get_meta(META_GDUNIT_NAME) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)] + item.set_text(0, "%s (%s retries)" % [item_text, retry_count]) + item.set_custom_color(0, Color.GREEN_YELLOW) + item.set_custom_color(1, Color.GREEN_YELLOW) + item.collapsed = false + set_item_icon_by_state(item) + + +func set_state_skipped(item: TreeItem) -> void: + item.set_meta(META_GDUNIT_STATE, STATE.SKIPPED) + item.set_text(1, "(skipped)") + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + item.set_custom_color(0, Color.DARK_GRAY) + item.set_custom_color(1, Color.DARK_GRAY) + item.collapsed = false + set_item_icon_by_state(item) + + +func set_state_warnings(item: TreeItem) -> void: + # Do not overwrite higher states + if is_state_error(item) or is_state_failed(item): + return + item.set_meta(META_GDUNIT_STATE, STATE.WARNING) + item.set_custom_color(0, Color.YELLOW) + item.set_custom_color(1, Color.YELLOW) + item.collapsed = false + set_item_icon_by_state(item) + + +func set_state_failed(item: TreeItem, event: GdUnitEvent) -> void: + # Do not overwrite higher states + if is_state_error(item): + return + var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT) + if retry_count > 1: + var item_text: String = item.get_meta(META_GDUNIT_NAME) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)] + item.set_text(0, "%s (%s retries)" % [item_text, retry_count]) + item.set_meta(META_GDUNIT_STATE, STATE.FAILED) + item.set_custom_color(0, Color.LIGHT_BLUE) + item.set_custom_color(1, Color.LIGHT_BLUE) + item.collapsed = false + set_item_icon_by_state(item) + + +func set_state_error(item: TreeItem) -> void: + item.set_meta(META_GDUNIT_STATE, STATE.ERROR) + item.set_custom_color(0, Color.ORANGE_RED) + item.set_custom_color(1, Color.ORANGE_RED) + set_item_icon_by_state(item) + item.collapsed = false + + +func set_state_aborted(item: TreeItem) -> void: + item.set_meta(META_GDUNIT_STATE, STATE.ABORDED) + item.set_custom_color(0, Color.ORANGE_RED) + item.set_custom_color(1, Color.ORANGE_RED) + item.clear_custom_bg_color(0) + item.set_text(1, "(aborted)") + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + set_item_icon_by_state(item) + item.collapsed = false + + +func set_state_orphan(item: TreeItem, event: GdUnitEvent) -> void: + var orphan_count := event.statistic(GdUnitEvent.ORPHAN_NODES) + if orphan_count == 0: + return + if item.has_meta(META_GDUNIT_ORPHAN): + orphan_count += item.get_meta(META_GDUNIT_ORPHAN) + item.set_meta(META_GDUNIT_ORPHAN, orphan_count) + if item.get_meta(META_GDUNIT_STATE) != STATE.FAILED: + item.set_custom_color(0, Color.YELLOW) + item.set_custom_color(1, Color.YELLOW) + item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count) + set_item_icon_by_state(item) + + +func update_state(item: TreeItem, event: GdUnitEvent, add_reports := true) -> void: + # we do not show the root + if item == null: + return + + if event.is_skipped(): + set_state_skipped(item) + elif event.is_success() and event.is_flaky(): + set_state_flaky(item, event) + elif event.is_success(): + set_state_succeded(item) + elif event.is_error(): + set_state_error(item) + elif event.is_failed(): + set_state_failed(item, event) + elif event.is_warning(): + set_state_warnings(item) + if add_reports: + for report in event.reports(): + add_report(item, report) + set_state_orphan(item, event) + + var parent := item.get_parent() + if parent == null: + return + + var item_state: int = item.get_meta(META_GDUNIT_STATE) + var parent_state: int = parent.get_meta(META_GDUNIT_STATE) + if item_state <= parent_state: + return + update_state(item.get_parent(), event, false) + + +func add_report(item: TreeItem, report: GdUnitReport) -> void: + var reports: Array[GdUnitReport] = [] + if item.has_meta(META_GDUNIT_REPORT): + reports = get_item_reports(item) + reports.append(report) + item.set_meta(META_GDUNIT_REPORT, reports) + + +func abort_running(items:=_tree_root.get_children()) -> void: + for item in items: + if is_state_running(item): + set_state_aborted(item) + abort_running(item.get_children()) + + +func _on_select_next_item_by_state(item_state: int) -> TreeItem: + var current_selected := _tree.get_selected() + # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found + current_selected = _find_first_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state) + # If no next failure found, then we try to select first + if current_selected == null: + current_selected = _find_first_item_by_state(_tree_root, item_state) + return select_item(current_selected) + + +func _on_select_previous_item_by_state(item_state: int) -> TreeItem: + var current_selected := _tree.get_selected() + # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found + current_selected = _find_last_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state, true) + # If no next failure found, then we try to select first last + if current_selected == null: + current_selected = _find_last_item_by_state(_tree_root, item_state) + return select_item(current_selected) + + +func select_first_orphan() -> void: + for parent in _tree_root.get_children(): + if not is_state_success(parent): + for item in parent.get_children(): + if is_item_state_orphan(item): + parent.set_collapsed(false) + @warning_ignore("return_value_discarded") + select_item(item) + return + + +func clear_reports() -> void: + for child in _report_list.get_children(): + _report_list.remove_child(child) + child.queue_free() + + +func show_failed_report(selected_item: TreeItem) -> void: + clear_reports() + if selected_item == null or not selected_item.has_meta(META_GDUNIT_REPORT): + return + # add new reports + for report in get_item_reports(selected_item): + var reportNode: RichTextLabel = _report_template.duplicate() + _report_list.add_child(reportNode) + reportNode.append_text(report.to_string()) + reportNode.visible = true + + +func update_test_suite(event: GdUnitEvent) -> void: + var item := _find_tree_item_by_test_suite(_tree_root, event.resource_path(), event.suite_name()) + if not item: + push_error("[InspectorTreeMainPanel#update_test_suite] Internal Error: Can't find test suite item '{_suite_name}' for {_resource_path} ".format(event)) + return + if event.type() == GdUnitEvent.TESTSUITE_AFTER: + update_item_elapsed_time_counter(item, event.elapsed_time()) + update_state(item, event) + set_state_running(item, false) + + +func update_test_case(event: GdUnitEvent) -> void: + var item := _find_tree_item_by_id(_tree_root, event.guid()) + if not item: + #push_error("Internal Error: Can't find test id %s" % [event.guid()]) + return + if event.type() == GdUnitEvent.TESTCASE_BEFORE: + set_state_running(item, true) + # force scrolling to current test case + _tree.scroll_to_item(item, true) + return + + if event.type() == GdUnitEvent.TESTCASE_AFTER: + update_item_elapsed_time_counter(item, event.elapsed_time()) + if event.is_success() or event.is_warning(): + update_item_processed_counter(item) + update_state(item, event) + update_progress_counters(item) + + +func create_item(parent: TreeItem, test: GdUnitTestCase, item_name: String, type: GdUnitType) -> TreeItem: + var item := _tree.create_item(parent) + item.collapsed = true + item.set_meta(META_GDUNIT_ORIGINAL_INDEX, item.get_index()) + item.set_text(0, item_name) + match type: + GdUnitType.TEST_CASE: + item.set_meta(META_TEST_CASE, test) + GdUnitType.TEST_GROUP: + # We need to create a copy of the test record meta with a new uniqe guid + item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.test_name)) + GdUnitType.TEST_SUITE: + # We need to create a copy of the test record meta with a new uniqe guid + item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.suite_name)) + + item.set_meta(META_GDUNIT_NAME, item_name) + set_state_initial(item, type) + update_item_total_counter(item) + return item + + +func set_item_icon_by_state(item :TreeItem) -> void: + if item == _tree_root: + return + var state :STATE = item.get_meta(META_GDUNIT_STATE) + var is_orphan := is_item_state_orphan(item) + var resource_path := get_item_source_file(item) + item.set_icon(0, get_icon_by_file_type(resource_path, state, is_orphan)) + if item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER: + item.set_icon_modulate(0, Color.SKY_BLUE) + + +func update_item_total_counter(item: TreeItem) -> void: + if item == null: + return + + var child_count := get_total_child_count(item) + if child_count > 0: + item.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, child_count) + item.set_text(0, "(0/%d) %s" % [child_count, item.get_meta(META_GDUNIT_NAME)]) + + update_item_total_counter(item.get_parent()) + + +func get_total_child_count(item: TreeItem) -> int: + var total_count := 0 + for child in item.get_children(): + total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) else 1 + return total_count + + +func update_item_processed_counter(item: TreeItem, add_count := 1) -> void: + if item == _tree_root: + return + + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + add_count + item.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + item.set_text(0, "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]) + + update_item_processed_counter(item.get_parent(), add_count) + + +func update_progress_counters(item: TreeItem) -> void: + var index: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_INDEX) + 1 + var total_test: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) + var state: STATE = item.get_meta(META_GDUNIT_STATE) + test_counters_changed.emit(index, total_test, state) + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, index) + + +func recalculate_counters(parent: TreeItem) -> void: + # Reset the counter first + if parent.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + if parent.has_meta(META_GDUNIT_PROGRESS_INDEX): + parent.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + if parent.has_meta(META_GDUNIT_SUCCESS_TESTS): + parent.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + + # Calculate new count based on children + var total_count := 0 + var success_count := 0 + var progress_index := 0 + + for child in parent.get_children(): + if child.get_child_count() > 0: + # Recursively update child counters first + recalculate_counters(child) + # Add child's counters to parent + if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) + if child.has_meta(META_GDUNIT_SUCCESS_TESTS): + success_count += child.get_meta(META_GDUNIT_SUCCESS_TESTS) + if child.has_meta(META_GDUNIT_PROGRESS_INDEX): + progress_index += child.get_meta(META_GDUNIT_PROGRESS_INDEX) + elif is_test_case(child): + # Count individual test cases + total_count += 1 + # Count completed tests + if is_state_success(child) or is_state_warning(child) or is_state_failed(child) or is_state_error(child): + progress_index += 1 + if is_state_success(child) or is_state_warning(child): + success_count += 1 + + # Update the counters + if total_count > 0: + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_count) + parent.set_meta(META_GDUNIT_PROGRESS_INDEX, progress_index) + parent.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count) + + # Update the display text + parent.set_text(0, "(%d/%d) %s" % [success_count, total_count, parent.get_meta(META_GDUNIT_NAME)]) + + +func update_item_elapsed_time_counter(item: TreeItem, time: int) -> void: + item.set_text(1, "%s" % LocalTime.elapsed(time)) + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + item.set_meta(META_GDUNIT_EXECUTION_TIME, time) + + var parent := item.get_parent() + if parent == _tree_root: + return + var elapsed_time :int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time + var type :GdUnitType = item.get_meta(META_GDUNIT_TYPE) + match type: + GdUnitType.TEST_CASE: + return + GdUnitType.TEST_SUITE: + update_item_elapsed_time_counter(parent, elapsed_time) + #GdUnitType.FOLDER: + # update_item_elapsed_time_counter(parent, elapsed_time) + + +func get_icon_by_file_type(path: String, state: STATE, orphans: bool) -> Texture2D: + if path.get_extension() == "gd": + match state: + STATE.INITIAL: + return ICON_GDSCRIPT_TEST_DEFAULT + STATE.SUCCESS: + return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_SUCCESS + STATE.ERROR: + return ICON_GDSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_ERROR + STATE.FAILED: + return ICON_GDSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FAILED + STATE.WARNING: + return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_DEFAULT + STATE.FLAKY: + return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FLAKY + _: + return ICON_GDSCRIPT_TEST_DEFAULT + if path.get_extension() == "cs": + match state: + STATE.INITIAL: + return ICON_CSSCRIPT_TEST_DEFAULT + STATE.SUCCESS: + return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_SUCCESS + STATE.ERROR: + return ICON_CSSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_ERROR + STATE.FAILED: + return ICON_CSSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_CSSCRIPT_TEST_FAILED + STATE.WARNING: + return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_DEFAULT + _: + return ICON_CSSCRIPT_TEST_DEFAULT + match state: + STATE.INITIAL: + return ICON_FOLDER + STATE.ERROR: + return ICON_FOLDER + STATE.FAILED: + return ICON_FOLDER + _: + return ICON_FOLDER + + +func on_test_case_discover_added(test_case: GdUnitTestCase) -> void: + var test_root_folder := GdUnitSettings.test_root_folder().replace("res://", "") + var fully_qualified_name := test_case.fully_qualified_name.trim_suffix(test_case.display_name) + var parts := fully_qualified_name.split(".", false) + parts.append(test_case.display_name) + # Skip tree structure until test root folder + var index := parts.find(test_root_folder) + if index != -1: + parts = parts.slice(index+1) + + match _current_tree_view_mode: + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT: + create_items_tree_mode_flat(test_case, parts) + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE: + create_items_tree_mode_tree(test_case, parts) + + +func create_items_tree_mode_tree(test_case: GdUnitTestCase, parts: PackedStringArray) -> void: + var parent := _tree_root + var is_suite_assigned := false + var suite_name := test_case.suite_name.split(".")[-1] + for item_name in parts: + var next := _find_tree_item(parent, item_name) + if next != null: + parent = next + continue + + if not is_suite_assigned and suite_name == item_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_SUITE) + is_suite_assigned = true + elif item_name == test_case.display_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_CASE) + # On grouped tests (parameterized tests) + elif item_name == test_case.test_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_GROUP) + else: + next = create_item(parent, test_case, item_name, GdUnitType.FOLDER) + parent = next + + +func create_items_tree_mode_flat(test_case: GdUnitTestCase, parts: PackedStringArray) -> void: + # All parts except the last two (suite name and test name/display name) + var slice_index := -2 if parts[-1] == test_case.test_name else -3 + var path_parts := parts.slice(0, slice_index) + var folder_path := "/".join(path_parts) + + # Find or create flat folder + var folder_item: TreeItem + if folder_path.is_empty(): + folder_item = _tree_root + else: + folder_item = _find_tree_item(_tree_root, folder_path) + if folder_item == null: + folder_item = create_item(_tree_root, test_case, folder_path, GdUnitType.FOLDER) + + # Find suite under the flat folder (second to last part) + var suite_item := _find_tree_item(folder_item, test_case.suite_name) + if suite_item == null: + suite_item = create_item(folder_item, test_case, test_case.suite_name, GdUnitType.TEST_SUITE) + + # Add test case or group under the suite + if test_case.test_name != test_case.display_name: + # It's a parameterized test group + var group_item := _find_tree_item(suite_item, test_case.test_name) + if group_item == null: + group_item = create_item(suite_item, test_case, test_case.test_name, GdUnitType.TEST_GROUP) + create_item(group_item, test_case, test_case.display_name, GdUnitType.TEST_CASE) + else: + create_item(suite_item, test_case, test_case.display_name, GdUnitType.TEST_CASE) + + +func on_test_case_discover_deleted(test_case: GdUnitTestCase) -> void: + var item := _find_tree_item_by_id(_tree_root, test_case.guid) + if item != null: + var parent := item.get_parent() + parent.remove_child(item) + + # update the cached counters + var item_success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + var item_total_test_count: int = item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + var total_test_count: int = parent.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count-item_total_test_count) + + # propagate counter update to all parents + update_item_total_counter(parent) + update_item_processed_counter(parent, -item_success_count) + + +func on_test_case_discover_modified(test_case: GdUnitTestCase) -> void: + var item := _find_tree_item_by_id(_tree_root, test_case.guid) + if item != null: + item.set_meta(META_TEST_CASE, test_case) + item.set_text(0, test_case.display_name) + item.set_meta(META_GDUNIT_NAME, test_case.display_name) + + +func get_item_reports(item: TreeItem) -> Array[GdUnitReport]: + return item.get_meta(META_GDUNIT_REPORT) + + +func get_item_test_line_number(item: TreeItem) -> int: + if item == null or not item.has_meta(META_TEST_CASE): + return -1 + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.line_number + + +func get_item_source_file(item: TreeItem) -> String: + if item == null or not item.has_meta(META_TEST_CASE): + return "" + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.source_file + + +func get_item_type(item: TreeItem) -> GdUnitType: + if item == null or not item.has_meta(META_GDUNIT_TYPE): + return GdUnitType.FOLDER + return item.get_meta(META_GDUNIT_TYPE) + + +func _dump_tree_as_json(dump_name: String) -> void: + var dict := _to_json(_tree_root) + var file := FileAccess.open("res://%s.json" % dump_name, FileAccess.WRITE) + file.store_string(JSON.stringify(dict, "\t")) + + +func _to_json(parent :TreeItem) -> Dictionary: + var item_as_dict := GdObjects.obj2dict(parent) + item_as_dict["TreeItem"]["childrens"] = parent.get_children().map(func(item: TreeItem) -> Dictionary: + return _to_json(item)) + return item_as_dict + + +func extract_resource_path(event: GdUnitEvent) -> String: + return ProjectSettings.localize_path(event.resource_path()) + + +func collect_test_cases(item: TreeItem, tests: Array[GdUnitTestCase] = []) -> Array[GdUnitTestCase]: + for next in item.get_children(): + collect_test_cases(next, tests) + + if is_test_case(item): + var test: GdUnitTestCase = item.get_meta(META_TEST_CASE) + if not tests.has(test): + tests.append(test) + + return tests + + +################################################################################ +# Tree signal receiver +################################################################################ +func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int) -> void: + if mouse_button_index == MOUSE_BUTTON_RIGHT: + _context_menu.position = get_screen_position() + mouse_position + _context_menu.popup() + + +func _on_run_pressed(run_debug: bool) -> void: + _context_menu.hide() + var item: = _tree.get_selected() + if item == null: + print_rich("[color=GOLDENROD]Abort Testrun, no test suite selected![/color]") + return + + var test_to_execute := collect_test_cases(item) + GdUnitCommandHandler.instance().cmd_run_tests(test_to_execute, run_debug) + + +func _on_Tree_item_selected() -> void: + # only show report checked manual item selection + # we need to check the run mode here otherwise it will be called every selection + if not _context_menu.is_item_disabled(CONTEXT_MENU_RUN_ID): + var selected_item: TreeItem = _tree.get_selected() + show_failed_report(selected_item) + _current_selected_item = _tree.get_selected() + tree_item_selected.emit(_current_selected_item) + + +# Opens the test suite +func _on_Tree_item_activated() -> void: + var selected_item := _tree.get_selected() + var line_number := get_item_test_line_number(selected_item) + if line_number != -1: + var script_path := ProjectSettings.localize_path(get_item_source_file(selected_item)) + var resource: Script = load(script_path) + + if selected_item.has_meta(META_GDUNIT_REPORT): + var reports := get_item_reports(selected_item) + var report_line_number := reports[0].line_number() + # if number -1 we use original stored line number of the test case + # in non debug mode the line number is not available + if report_line_number != -1: + line_number = report_line_number + + EditorInterface.get_file_system_dock().navigate_to_path(script_path) + EditorInterface.edit_script(resource, line_number) + elif selected_item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER: + # Toggle collapse if dir + selected_item.collapsed = not selected_item.collapsed + + +################################################################################ +# external signal receiver +################################################################################ +func _on_gdunit_runner_start() -> void: + _context_menu.set_item_disabled(CONTEXT_MENU_RUN_ID, true) + _context_menu.set_item_disabled(CONTEXT_MENU_DEBUG_ID, true) + reset_tree_state(_tree_root) + clear_reports() + + +func _on_gdunit_runner_stop(_id: int) -> void: + _context_menu.set_item_disabled(CONTEXT_MENU_RUN_ID, false) + _context_menu.set_item_disabled(CONTEXT_MENU_DEBUG_ID, false) + abort_running() + sort_tree_items(_tree_root) + # wait until the tree redraw + await get_tree().process_frame + var failure_item := _find_first_item_by_state(_tree_root, STATE.FAILED) + select_item( failure_item if failure_item else _current_selected_item) + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.DISCOVER_START: + _tree_root.visible = false + _discover_hint.visible = true + init_tree() + + GdUnitEvent.DISCOVER_END: + sort_tree_items(_tree_root) + select_item(_tree_root.get_first_child()) + _discover_hint.visible = false + _tree_root.visible = true + #_dump_tree_as_json("tree_example_discovered") + + GdUnitEvent.INIT: + _on_gdunit_runner_start() + + GdUnitEvent.TESTCASE_BEFORE: + update_test_case(event) + + GdUnitEvent.TESTCASE_AFTER: + update_test_case(event) + + GdUnitEvent.TESTSUITE_BEFORE: + update_test_suite(event) + + GdUnitEvent.TESTSUITE_AFTER: + update_test_suite(event) + + +func _on_context_m_index_pressed(index: int) -> void: + match index: + CONTEXT_MENU_DEBUG_ID: + _on_run_pressed(true) + CONTEXT_MENU_RUN_ID: + _on_run_pressed(false) + CONTEXT_MENU_EXPAND_ALL: + do_collapse_all(false) + CONTEXT_MENU_COLLAPSE_ALL: + do_collapse_all(true) + + +func _on_settings_changed(property :GdUnitProperty) -> void: + match property.name(): + GdUnitSettings.INSPECTOR_TREE_SORT_MODE: + sort_tree_items(_tree_root) + #_dump_tree_as_json("tree_sorted_by_%s" % GdUnitInspectorTreeConstants.SORT_MODE.keys()[property.value()]) + + GdUnitSettings.INSPECTOR_TREE_VIEW_MODE: + restructure_tree(_tree_root, GdUnitSettings.get_inspector_tree_view_mode()) diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn b/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd new file mode 100644 index 00000000..14a8bd5b --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd @@ -0,0 +1,327 @@ +@tool +extends Window + +const EAXAMPLE_URL := "https://github.com/MikeSchulze/gdUnit4-examples/archive/refs/heads/master.zip" +const GdUnitTools := preload ("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const GdUnitUpdateClient = preload ("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") + +@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient +@onready var _version_label: RichTextLabel = %version +@onready var _btn_install: Button = %btn_install_examples +@onready var _progress_bar: ProgressBar = %ProgressBar +@onready var _progress_text: Label = %progress_lbl +@onready var _properties_template: Control = $property_template +@onready var _properties_common: Control = % "common-content" +@onready var _properties_ui: Control = % "ui-content" +@onready var _properties_shortcuts: Control = % "shortcut-content" +@onready var _properties_report: Control = % "report-content" +@onready var _input_capture: GdUnitInputCapture = %GdUnitInputCapture +@onready var _property_error: Window = % "propertyError" +@onready var _tab_container: TabContainer = %Properties +@onready var _update_tab: Control = %Update + +var _font_size: float + + +func _ready() -> void: + set_name("GdUnitSettingsDialog") + # initialize for testing + if not Engine.is_editor_hint(): + GdUnitSettings.setup() + GdUnit4Version.init_version_label(_version_label) + _font_size = GdUnitFonts.init_fonts(_version_label) + setup_properties(_properties_common, GdUnitSettings.COMMON_SETTINGS) + setup_properties(_properties_ui, GdUnitSettings.UI_SETTINGS) + setup_properties(_properties_report, GdUnitSettings.REPORT_SETTINGS) + setup_properties(_properties_shortcuts, GdUnitSettings.SHORTCUT_SETTINGS) + check_for_update() + + +func _sort_by_key(left: GdUnitProperty, right: GdUnitProperty) -> bool: + return left.name() < right.name() + + +func setup_properties(properties_parent: Control, property_category: String) -> void: + # Do remove first potential previous added properties (could be happened when the dlg is opened at twice) + for child in properties_parent.get_children(): + properties_parent.remove_child(child) + + var category_properties := GdUnitSettings.list_settings(property_category) + # sort by key + category_properties.sort_custom(_sort_by_key) + var theme_ := Theme.new() + theme_.set_constant("h_separation", "GridContainer", 12) + var last_category := "!" + var min_size_overall := 0.0 + var labels := [] + var inputs := [] + var info_labels := [] + var grid: GridContainer = null + for p in category_properties: + var min_size_ := 0.0 + var property: GdUnitProperty = p + var current_category := property.category() + if not grid or current_category != last_category: + grid = GridContainer.new() + grid.columns = 4 + grid.theme = theme_ + + var sub_category: Control = _properties_template.get_child(3).duplicate() + var category_label: Label = sub_category.get_child(0) + category_label.text = current_category.capitalize() + sub_category.custom_minimum_size.y = _font_size + 16 + properties_parent.add_child(sub_category) + properties_parent.add_child(grid) + last_category = current_category + # property name + var label: Label = _properties_template.get_child(0).duplicate() + label.text = _to_human_readable(property.name()) + labels.append(label) + grid.add_child(label) + + # property reset btn + var reset_btn: Button = _properties_template.get_child(1).duplicate() + reset_btn.icon = _get_btn_icon("Reload") + reset_btn.disabled = property.value() == property.default() + grid.add_child(reset_btn) + + # property type specific input element + var input: Node = _create_input_element(property, reset_btn) + inputs.append(input) + grid.add_child(input) + @warning_ignore("return_value_discarded") + reset_btn.pressed.connect(_on_btn_property_reset_pressed.bind(property, input, reset_btn)) + # property help text + var info: Label = _properties_template.get_child(2).duplicate() + info.text = property.help() + info_labels.append(info) + grid.add_child(info) + if min_size_overall < min_size_: + min_size_overall = min_size_ + + for controls: Array in [labels, inputs, info_labels]: + var _size: float = controls.map(func(c: Control) -> float: return c.size.x).max() + min_size_overall += _size + for control: Control in controls: + control.custom_minimum_size.x = _size + properties_parent.custom_minimum_size.x = min_size_overall + + +func _create_input_element(property: GdUnitProperty, reset_btn: Button) -> Node: + if property.is_selectable_value(): + var options := OptionButton.new() + options.alignment = HORIZONTAL_ALIGNMENT_CENTER + for value in property.value_set(): + options.add_item(value) + options.item_selected.connect(_on_option_selected.bind(property, reset_btn)) + options.select(property.int_value()) + return options + if property.type() == TYPE_BOOL: + var check_btn := CheckButton.new() + check_btn.toggled.connect(_on_property_text_changed.bind(property, reset_btn)) + check_btn.button_pressed = property.value() + return check_btn + if property.type() in [TYPE_INT, TYPE_STRING]: + var input := LineEdit.new() + input.text_changed.connect(_on_property_text_changed.bind(property, reset_btn)) + input.set_context_menu_enabled(false) + input.set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER) + input.set_expand_to_text_length_enabled(true) + input.text = str(property.value()) + return input + if property.type() == TYPE_PACKED_INT32_ARRAY: + var key_input_button := Button.new() + var value:PackedInt32Array = property.value() + key_input_button.text = to_shortcut(value) + key_input_button.pressed.connect(_on_shortcut_change.bind(key_input_button, property, reset_btn)) + return key_input_button + return Control.new() + + +func to_shortcut(keys: PackedInt32Array) -> String: + var input_event := InputEventKey.new() + for key in keys: + match key: + KEY_CTRL: input_event.ctrl_pressed = true + KEY_SHIFT: input_event.shift_pressed = true + KEY_ALT: input_event.alt_pressed = true + KEY_META: input_event.meta_pressed = true + _: + input_event.keycode = key as Key + return input_event.as_text() + + +func to_keys(input_event: InputEventKey) -> PackedInt32Array: + var keys := PackedInt32Array() + if input_event.ctrl_pressed: + keys.append(KEY_CTRL) + if input_event.shift_pressed: + keys.append(KEY_SHIFT) + if input_event.alt_pressed: + keys.append(KEY_ALT) + if input_event.meta_pressed: + keys.append(KEY_META) + keys.append(input_event.keycode) + return keys + + +func _to_human_readable(value: String) -> String: + return value.split("/")[-1].capitalize() + + +func _get_btn_icon(p_name: String) -> Texture2D: + if not Engine.is_editor_hint(): + var placeholder := PlaceholderTexture2D.new() + placeholder.size = Vector2(8, 8) + return placeholder + return GdUnitUiTools.get_icon(p_name) + + +func _install_examples() -> void: + _init_progress(5) + update_progress("Downloading examples") + await get_tree().process_frame + var tmp_path := GdUnitFileAccess.create_temp_dir("download") + var zip_file := tmp_path + "/examples.zip" + var response: GdUnitUpdateClient.HttpResponse = await _update_client.request_zip_package(EAXAMPLE_URL, zip_file) + if response.status() != 200: + push_warning("Examples cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()]) + update_progress("Install examples failed! Try it later again.") + await get_tree().create_timer(3).timeout + stop_progress() + return + # extract zip to tmp + update_progress("Install examples into project") + var result := GdUnitFileAccess.extract_zip(zip_file, "res://gdUnit4-examples/") + if result.is_error(): + update_progress("Install examples failed! %s" % result.error_message()) + await get_tree().create_timer(3).timeout + stop_progress() + return + update_progress("Refresh project") + await rescan() + await reimport("res://gdUnit4-examples/") + + update_progress("Examples successfully installed") + await get_tree().create_timer(3).timeout + stop_progress() + + +func rescan() -> void: + await get_tree().process_frame + var fs := EditorInterface.get_resource_filesystem() + fs.scan_sources() + while fs.is_scanning(): + await get_tree().create_timer(1).timeout + + +func reimport(path: String) -> void: + await get_tree().process_frame + var files := DirAccess.get_files_at(path) + EditorInterface.get_resource_filesystem().reimport_files(files) + for directory in DirAccess.get_directories_at(path): + reimport(directory) + + +func check_for_update() -> void: + if not GdUnitSettings.is_update_notification_enabled(): + return + var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version() + if response.status() != 200: + printerr("Latest version information cannot be retrieved from GitHub!") + printerr("Error: %s" % response.response()) + return + var latest_version := _update_client.extract_latest_version(response) + if latest_version.is_greater(GdUnit4Version.current()): + var tab_index := _tab_container.get_tab_idx_from_control(_update_tab) + _tab_container.set_tab_button_icon(tab_index, GdUnitUiTools.get_icon("Notification", Color.YELLOW)) + _tab_container.set_tab_tooltip(tab_index, "An new update is available.") + + +func _on_btn_report_bug_pressed() -> void: + @warning_ignore("return_value_discarded") + OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=bug&projects=projects%2F5&template=bug_report.yml&title=GD-XXX%3A+Describe+the+issue+briefly") + + +func _on_btn_request_feature_pressed() -> void: + @warning_ignore("return_value_discarded") + OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=enhancement&projects=&template=feature_request.md&title=") + + +func _on_btn_install_examples_pressed() -> void: + _btn_install.disabled = true + await _install_examples() + _btn_install.disabled = false + + +func _on_btn_close_pressed() -> void: + hide() + + +func _on_btn_property_reset_pressed(property: GdUnitProperty, input: Node, reset_btn: Button) -> void: + if input is CheckButton: + var is_default_pressed: bool = property.default() + (input as CheckButton).button_pressed = is_default_pressed + elif input is LineEdit: + (input as LineEdit).text = str(property.default()) + # we have to update manually for text input fields because of no change event is emited + _on_property_text_changed(property.default(), property, reset_btn) + elif input is OptionButton: + (input as OptionButton).select(0) + _on_option_selected(0, property, reset_btn) + elif input is Button: + var value: PackedInt32Array = property.default() + (input as Button).text = to_shortcut(value) + _on_property_text_changed(value, property, reset_btn) + + +func _on_property_text_changed(new_value: Variant, property: GdUnitProperty, reset_btn: Button) -> void: + property.set_value(new_value) + reset_btn.disabled = property.value() == property.default() + var error: Variant = GdUnitSettings.update_property(property) + if error: + var label: Label = _property_error.get_child(0) as Label + label.set_text(str(error)) + var control := gui_get_focus_owner() + _property_error.show() + if control != null: + _property_error.position = control.global_position + Vector2(self.position) + Vector2(40, 40) + + +func _on_option_selected(index: int, property: GdUnitProperty, reset_btn: Button) -> void: + property.set_value(index) + reset_btn.disabled = property.value() == property.default() + GdUnitSettings.update_property(property) + + +func _on_shortcut_change(input_button: Button, property: GdUnitProperty, reset_btn: Button) -> void: + _input_capture.set_custom_minimum_size(_properties_shortcuts.get_size()) + _input_capture.visible = true + _input_capture.show() + _properties_shortcuts.visible = false + set_process_input(false) + _input_capture.reset() + var input_event: InputEventKey = await _input_capture.input_completed + input_button.text = input_event.as_text() + _on_property_text_changed(to_keys(input_event), property, reset_btn) + _properties_shortcuts.visible = true + set_process_input(true) + + +func _init_progress(max_value: int) -> void: + _progress_bar.visible = true + _progress_bar.max_value = max_value + _progress_bar.value = 0 + + +func _progress() -> void: + _progress_bar.value += 1 + + +func stop_progress() -> void: + _progress_bar.visible = false + + +func update_progress(message: String) -> void: + _progress_text.text = message + _progress_bar.value += 1 diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/logo.png b/addons/gdUnit4/src/ui/settings/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/settings/logo.png.import b/addons/gdUnit4/src/ui/settings/logo.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd new file mode 100644 index 00000000..be5c3528 --- /dev/null +++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd @@ -0,0 +1,129 @@ +@tool +extends MarginContainer + +@onready var _template_editor :CodeEdit = $VBoxContainer/EdiorLayout/Editor +@onready var _tags_editor :CodeEdit = $Tags/MarginContainer/TextEdit +@onready var _title_bar :Panel = $VBoxContainer/sub_category +@onready var _save_button :Button = $VBoxContainer/Panel/HBoxContainer/Save +@onready var _selected_type :OptionButton = $VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/SelectType +@onready var _show_tags :PopupPanel = $Tags + + +var gd_key_words :PackedStringArray = ["extends", "class_name", "const", "var", "onready", "func", "void", "pass"] +var gdunit_key_words :PackedStringArray = ["GdUnitTestSuite", "before", "after", "before_test", "after_test"] +var _selected_template :int + + +func _ready() -> void: + setup_editor_colors() + setup_fonts() + setup_supported_types() + load_template(GdUnitTestSuiteTemplate.TEMPLATE_ID_GD) + setup_tags_help() + + +func _notification(what :int) -> void: + if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED: + setup_fonts() + + +func setup_editor_colors() -> void: + if not Engine.is_editor_hint(): + return + + var background_color := get_editor_color("text_editor/theme/highlighting/background_color", Color(0.1155, 0.132, 0.1595, 1)) + var text_color := get_editor_color("text_editor/theme/highlighting/text_color", Color(0.8025, 0.81, 0.8225, 1)) + var selection_color := get_editor_color("text_editor/theme/highlighting/selection_color", Color(0.44, 0.73, 0.98, 0.4)) + + for e :CodeEdit in [_template_editor, _tags_editor]: + var editor :CodeEdit = e + editor.add_theme_color_override("background_color", background_color) + editor.add_theme_color_override("font_color", text_color) + editor.add_theme_color_override("font_readonly_color", text_color) + editor.add_theme_color_override("font_selected_color", selection_color) + setup_highlighter(editor) + + +func setup_highlighter(editor :CodeEdit) -> void: + var highlighter := CodeHighlighter.new() + editor.set_syntax_highlighter(highlighter) + var number_color := get_editor_color("text_editor/theme/highlighting/number_color", Color(0.63, 1, 0.88, 1)) + var symbol_color := get_editor_color("text_editor/theme/highlighting/symbol_color", Color(0.67, 0.79, 1, 1)) + var function_color := get_editor_color("text_editor/theme/highlighting/function_color", Color(0.34, 0.7, 1, 1)) + var member_variable_color := get_editor_color("text_editor/theme/highlighting/member_variable_color", Color(0.736, 0.88, 1, 1)) + var comment_color := get_editor_color("text_editor/theme/highlighting/comment_color", Color(0.8025, 0.81, 0.8225, 0.5)) + var keyword_color := get_editor_color("text_editor/theme/highlighting/keyword_color", Color(1, 0.44, 0.52, 1)) + var base_type_color := get_editor_color("text_editor/theme/highlighting/base_type_color", Color(0.26, 1, 0.76, 1)) + var annotation_color := get_editor_color("text_editor/theme/highlighting/gdscript/annotation_color", Color(1, 0.7, 0.45, 1)) + + highlighter.clear_color_regions() + highlighter.clear_keyword_colors() + highlighter.add_color_region("#", "", comment_color, true) + highlighter.add_color_region("${", "}", Color.YELLOW) + highlighter.add_color_region("'", "'", Color.YELLOW) + highlighter.add_color_region("\"", "\"", Color.YELLOW) + highlighter.number_color = number_color + highlighter.symbol_color = symbol_color + highlighter.function_color = function_color + highlighter.member_variable_color = member_variable_color + highlighter.add_keyword_color("@", annotation_color) + highlighter.add_keyword_color("warning_ignore", annotation_color) + for word in gd_key_words: + highlighter.add_keyword_color(word, keyword_color) + for word in gdunit_key_words: + highlighter.add_keyword_color(word, base_type_color) + + +## Using this function to avoid null references to colors on inital Godot installations. +## For more details show https://github.com/MikeSchulze/gdUnit4/issues/533 +func get_editor_color(property_name: String, default: Color) -> Color: + var settings := EditorInterface.get_editor_settings() + return settings.get_setting(property_name) if settings.has_setting(property_name) else default + + +func setup_fonts() -> void: + if _template_editor: + @warning_ignore("return_value_discarded") + GdUnitFonts.init_fonts(_template_editor) + var font_size := GdUnitFonts.init_fonts(_tags_editor) + _title_bar.size.y = font_size + 16 + _title_bar.custom_minimum_size.y = font_size + 16 + + +func setup_supported_types() -> void: + _selected_type.clear() + _selected_type.add_item("GD - GDScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_GD) + _selected_type.add_item("C# - CSharpScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_CS) + + +func setup_tags_help() -> void: + _tags_editor.set_text(GdUnitTestSuiteTemplate.load_tags(_selected_template)) + + +func load_template(template_id :int) -> void: + _selected_template = template_id + _template_editor.set_text(GdUnitTestSuiteTemplate.load_template(template_id)) + + +func _on_Restore_pressed() -> void: + _template_editor.set_text(GdUnitTestSuiteTemplate.default_template(_selected_template)) + GdUnitTestSuiteTemplate.reset_to_default(_selected_template) + _save_button.disabled = true + + +func _on_Save_pressed() -> void: + GdUnitTestSuiteTemplate.save_template(_selected_template, _template_editor.get_text()) + _save_button.disabled = true + + +func _on_Tags_pressed() -> void: + _show_tags.popup_centered_ratio(.5) + + +func _on_Editor_text_changed() -> void: + _save_button.disabled = false + + +func _on_SelectType_item_selected(index :int) -> void: + load_template(_selected_type.get_item_id(index)) + setup_tags_help() diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd b/addons/gdUnit4/src/update/GdMarkDownReader.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd b/addons/gdUnit4/src/update/GdUnitPatch.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd.uid b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd b/addons/gdUnit4/src/update/GdUnitPatcher.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd b/addons/gdUnit4/src/update/GdUnitUpdate.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.tscn b/addons/gdUnit4/src/update/GdUnitUpdate.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png b/addons/gdUnit4/src/update/assets/border_bottom.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png.import b/addons/gdUnit4/src/update/assets/border_bottom.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/border_top.png b/addons/gdUnit4/src/update/assets/border_top.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/border_top.png.import b/addons/gdUnit4/src/update/assets/border_top.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/dot1.png b/addons/gdUnit4/src/update/assets/dot1.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/dot1.png.import b/addons/gdUnit4/src/update/assets/dot1.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/dot2.png b/addons/gdUnit4/src/update/assets/dot2.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/dot2.png.import b/addons/gdUnit4/src/update/assets/dot2.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/embedded.png b/addons/gdUnit4/src/update/assets/embedded.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/embedded.png.import b/addons/gdUnit4/src/update/assets/embedded.png.import new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png b/addons/gdUnit4/src/update/assets/horizontal-line2.png new file mode 100644 index 00000000..e69de29b diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png.import b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import new file mode 100644 index 00000000..e69de29b