summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/core/discovery
diff options
context:
space:
mode:
Diffstat (limited to 'addons/gdUnit4/src/core/discovery')
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd46
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd125
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd323
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd13
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd171
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid1
10 files changed, 683 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd
new file mode 100644
index 0000000..00332f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd
@@ -0,0 +1,46 @@
+## A class representing a globally unique identifier for GdUnit test elements.
+## Uses random values to generate unique identifiers that can be used
+## to track and reference test cases and suites across the test framework.
+class_name GdUnitGUID
+extends RefCounted
+
+
+## The internal string representation of the GUID.
+## Generated using Godot's ResourceUID system when no existing GUID is provided.
+var _guid: String
+
+
+## Creates a new GUID instance.
+## If no GUID is provided, generates a new one using Godot's ResourceUID system.
+func _init(from_guid: String = "") -> void:
+ if from_guid.is_empty():
+ _guid = _generate_guid()
+ else:
+ _guid = from_guid
+
+
+## Compares this GUID with another for equality.
+## Returns true if both GUIDs represent the same unique identifier.
+func equals(other: GdUnitGUID) -> bool:
+ return other._guid == _guid
+
+
+## Generates a custom GUID using random bytes.[br]
+## The format uses 16 random bytes encoded to hex and formatted with hyphens.
+static func _generate_guid() -> String:
+ # Pre-allocate array with exact size needed
+ var bytes := PackedByteArray()
+ bytes.resize(16)
+
+ # Fill with random bytes
+ for i in range(16):
+ bytes[i] = randi() % 256
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+
+ return bytes.hex_encode().insert(8, "-").insert(16, "-").insert(24, "-")
+
+
+func _to_string() -> String:
+ return _guid
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid
new file mode 100644
index 0000000..78730f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid
@@ -0,0 +1 @@
+uid://caisrhebqar04
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
new file mode 100644
index 0000000..766f9ea
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
@@ -0,0 +1,125 @@
+## GdUnitTestCase
+## A class representing a single test case in GdUnit4.
+## This class is used as a data container to hold all relevant information about a test case,
+## including its location, dependencies, and metadata for test discovery and execution.
+
+class_name GdUnitTestCase
+extends RefCounted
+
+## A unique identifier for the test case. Used to track and reference specific test instances.
+var guid := GdUnitGUID.new()
+
+## The resource path to the test suite
+var suite_resource_path: String
+
+## The name of the test method/function. Should start with "test_" prefix.
+var test_name: String
+
+## The class name of the test suite containing this test case.
+var suite_name: String
+
+## The fully qualified name of the test case following C# namespace pattern:
+## Constructed from the folder path (where folders are dot-separated), the test suite name, and the test case name.
+## All parts are joined by dots: {folder1.folder2.folder3}.{suite_name}.{test_name}
+var fully_qualified_name: String
+
+var display_name: String
+
+## Index tracking test attributes for ordered execution. Default is 0.
+## Higher values indicate later execution in the test sequence.
+var attribute_index: int
+
+## Flag indicating if this test requires the Godot runtime environment.
+## Tests requiring runtime cannot be executed in isolation.
+var require_godot_runtime: bool = true
+
+## The path to the source file containing this test case.
+## Used for test discovery and execution.
+var source_file: String
+
+## Optional holds the assembly location for C# tests
+var assembly_location: String = ""
+
+## The line number where the test case is defined in the source file.
+## Used for navigation and error reporting.
+var line_number: int = -1
+
+## Additional metadata about the test case, such as:
+## - tags: Array[String] - Test categories/tags for filtering
+## - timeout: int - Maximum execution time in milliseconds
+## - skip: bool - Whether the test should be skipped
+## - dependencies: Array[String] - Required test dependencies
+var metadata: Dictionary = {}
+
+
+static func from_dict(dict: Dictionary) -> GdUnitTestCase:
+ var test := GdUnitTestCase.new()
+ test.guid = GdUnitGUID.new(str(dict["guid"]))
+ test.suite_resource_path = dict["suite_resource_path"] if dict.has("suite_resource_path") else dict["source_file"]
+ test.suite_name = dict["managed_type"]
+ test.test_name = dict["test_name"]
+ test.display_name = dict["simple_name"]
+ test.fully_qualified_name = dict["fully_qualified_name"]
+ test.attribute_index = dict["attribute_index"]
+ test.source_file = dict["source_file"]
+ test.line_number = dict["line_number"]
+ test.require_godot_runtime = dict["require_godot_runtime"]
+ test.assembly_location = dict["assembly_location"]
+ return test
+
+
+static func to_dict(test: GdUnitTestCase) -> Dictionary:
+ return {
+ "guid": test.guid._guid,
+ "suite_resource_path": test.suite_resource_path,
+ "managed_type": test.suite_name,
+ "test_name" : test.test_name,
+ "simple_name" : test.display_name,
+ "fully_qualified_name" : test.fully_qualified_name,
+ "attribute_index" : test.attribute_index,
+ "source_file" : test.source_file,
+ "line_number" : test.line_number,
+ "require_godot_runtime" : test.require_godot_runtime,
+ "assembly_location" : test.assembly_location
+ }
+
+
+static func from(_suite_resource_path: String, _source_file: String, _line_number: int, _test_name: String, _attribute_index := -1, _test_parameters := "") -> GdUnitTestCase:
+ if(_source_file == null or _source_file.is_empty()):
+ prints(_test_name)
+
+ assert(_test_name != null and not _test_name.is_empty(), "Precondition: The parameter 'test_name' is not set")
+ assert(_source_file != null and not _source_file.is_empty(), "Precondition: The parameter 'source_file' is not set")
+
+ var test := GdUnitTestCase.new()
+ test.suite_resource_path = _suite_resource_path
+ test.test_name = _test_name
+ test.source_file = _source_file
+ test.line_number = _line_number
+ test.attribute_index = _attribute_index
+ test._build_suite_name()
+ test._build_display_name(_test_parameters)
+ test._build_fully_qualified_name(_suite_resource_path)
+ return test
+
+
+func _build_suite_name() -> void:
+ suite_name = source_file.get_file().get_basename()
+ assert(suite_name != null and not suite_name.is_empty(), "Precondition: The parameter 'suite_name' can't be resolved")
+
+
+func _build_display_name(_test_parameters: String) -> void:
+ if attribute_index == -1:
+ display_name = test_name
+ else:
+ display_name = "%s:%d (%s)" % [test_name, attribute_index, _test_parameters.trim_prefix("[").trim_suffix("]").replace('"', "'")]
+
+
+func _build_fully_qualified_name(_resource_path: String) -> void:
+ var name_space := _resource_path.trim_prefix("res://").trim_suffix(".gd").trim_suffix(".cs").replace("/", ".")
+
+ if attribute_index == -1:
+ fully_qualified_name = "%s.%s" % [name_space, test_name]
+ else:
+ fully_qualified_name = "%s.%s.%s" % [name_space, test_name, display_name]
+ assert(fully_qualified_name != null and not fully_qualified_name.is_empty(), "Precondition: The parameter 'fully_qualified_name' can't be resolved")
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid
new file mode 100644
index 0000000..0edbaff
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid
@@ -0,0 +1 @@
+uid://ct8h4c11708ha
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd
new file mode 100644
index 0000000..6af8255
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd
@@ -0,0 +1,323 @@
+## Guards and tracks test case changes during test discovery and file modifications.[br]
+## [br]
+## This guard maintains a cache of discovered tests to track changes between test runs and during[br]
+## file modifications. It is optimized for performance using simple but effective test identity checks.[br]
+## [br]
+## Test Change Detection:[br]
+## - Moved tests: The test implementation remains at a different line number[br]
+## - Renamed tests: The test line position remains but the test name changed[br]
+## - Deleted tests: A previously discovered test was removed[br]
+## - Added tests: A new test was discovered[br]
+## [br]
+## Cache Management:[br]
+## - Maintains test identity through unique GdUnitTestCase GUIDs[br]
+## - Maps source files to their discovered test cases[br]
+## - Tracks only essential metadata (line numbers, names) to minimize memory use[br]
+## [br]
+## Change Detection Strategy:[br]
+## The guard uses a lightweight approach by comparing only line numbers and test names.[br]
+## This avoids expensive operations like test content parsing or similarity checks.[br]
+## [br]
+## Event Handling:[br]
+## - Emits events on test changes through GdUnitSignals[br]
+## - Synchronizes cache with test discovery events[br]
+## - Notifies UI about test changes[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## # Create guard for tracking test changes
+## var guard := GdUnitTestDiscoverGuard.new()
+##
+## # Connect to test discovery events
+## GdUnitSignals.instance().gdunit_test_discovered.connect(guard.sync_test_added)
+##
+## # Discover tests and track changes
+## await guard.discover(test_script)
+## [/codeblock]
+class_name GdUnitTestDiscoverGuard
+extends Object
+
+
+
+static func instance() -> GdUnitTestDiscoverGuard:
+ return GdUnitSingleton.instance("GdUnitTestDiscoverGuard", func() -> GdUnitTestDiscoverGuard:
+ return GdUnitTestDiscoverGuard.new()
+ )
+
+
+## Maps source files to their discovered test cases.[br]
+## [br]
+## Key: Test suite source file path[br]
+## Value: Array of [class GdUnitTestCase] instances
+var _discover_cache := {}
+
+
+## Tracks discovered test changes for debug purposes.[br]
+## [br]
+## Available in debug mode only. Contains dictionaries:[br]
+## - changed_tests: Tests that were moved or renamed[br]
+## - deleted_tests: Tests that were removed[br]
+## - added_tests: New tests that were discovered
+var _discovered_changes := {}
+
+
+## Controls test change debug tracking.[br]
+## [br]
+## When true, maintains _discovered_changes for debugging.[br]
+## Used primarily in tests to verify change detection.
+var _is_debug := false
+
+
+## Creates a new guard instance.[br]
+## [br]
+## [param is_debug] When true, enables change tracking for debugging.
+func _init(is_debug := false) -> void:
+ _is_debug = is_debug
+ # Register for discovery events to sync the cache
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_test_discover_added.connect(sync_test_added)
+ GdUnitSignals.instance().gdunit_test_discover_deleted.connect(sync_test_deleted)
+ GdUnitSignals.instance().gdunit_test_discover_modified.connect(sync_test_modified)
+ GdUnitSignals.instance().gdunit_event.connect(handle_discover_events)
+
+
+## Adds a discovered test to the cache.[br]
+## [br]
+## [param test_case] The test case to add to the cache.
+func sync_test_added(test_case: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ test_cases.append(test_case)
+
+
+## Removes a test from the cache.[br]
+## [br]
+## [param test_case] The test case to remove from the cache.
+func sync_test_deleted(test_case: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ test_cases.erase(test_case)
+
+
+## Updates a test from the cache.[br]
+## [br]
+## [param test_case] The test case to update from the cache.
+func sync_test_modified(changed_test: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(changed_test.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ for test in test_cases:
+ if test.guid == changed_test.guid:
+ test.test_name = changed_test.test_name
+ test.display_name = changed_test.display_name
+ test.line_number = changed_test.line_number
+ break
+
+
+## Handles test discovery events.[br]
+## [br]
+## Resets the cache when a new discovery starts.[br]
+## [param event] The discovery event to handle.
+func handle_discover_events(event: GdUnitEvent) -> void:
+ # reset the cache on fresh discovery
+ if event.type() == GdUnitEvent.DISCOVER_START:
+ _discover_cache = {}
+
+
+## Registers a callback for discovered tests.[br]
+## [br]
+## Default sink writes to [class GdUnitTestDiscoverSink].
+static func default_discover_sink(test_case: GdUnitTestCase) -> void:
+ GdUnitTestDiscoverSink.discover(test_case)
+
+
+## Finds a test case by its unique identifier.[br]
+## [br]
+## Searches through all cached test cases across all test suites[br]
+## to find a test with the matching GUID.[br]
+## [br]
+## [param id] The GUID of the test to find[br]
+## Returns the matching test case or null if not found.
+func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
+ for test_sets: Array[GdUnitTestCase] in _discover_cache.values():
+ for test in test_sets:
+ if test.guid.equals(id):
+ return test
+
+ return null
+
+
+func get_discovered_tests() -> Array[GdUnitTestCase]:
+ var discovered_tests: Array[GdUnitTestCase] = []
+ for test_sets: Array[GdUnitTestCase] in _discover_cache.values():
+ discovered_tests.append_array(test_sets)
+ return discovered_tests
+
+
+## Discovers tests in a script and tracks changes.[br]
+## [br]
+## Handles both GDScript and C# test suites.[br]
+## The guard maintains test identity through changes.[br]
+## [br]
+## [param script] The test script to analyze[br]
+## [param discover_sink] Optional callback for test discovery events
+func discover(script: Script, discover_sink: Callable = default_discover_sink) -> void:
+ # Verify the script has no errors before run test discovery
+ var result := script.reload(true)
+ if result != OK:
+ return
+
+ if _is_debug:
+ _discovered_changes["changed_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ _discovered_changes["deleted_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ _discovered_changes["added_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+
+ if GdUnitTestSuiteScanner.is_test_suite(script):
+ # for cs scripts we need to recomplie before discover new tests
+ if script.get_class() == "CSharpScript":
+ await rebuild_project(script)
+
+ # rediscover all tests
+ var source_file := script.resource_path
+ var discovered_tests: Array[GdUnitTestCase] = []
+
+ GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void:
+ discovered_tests.append(test_case)
+ )
+
+ # The suite is never discovered, we add all discovered tests
+ if not _discover_cache.has(source_file):
+ for test_case in discovered_tests:
+ discover_sink.call(test_case)
+ return
+
+ sync_moved_tests(source_file, discovered_tests)
+ sync_renamed_tests(source_file, discovered_tests)
+ sync_deleted_tests(source_file, discovered_tests)
+ sync_added_tests(source_file, discovered_tests, discover_sink)
+
+
+## Synchronizes moved tests between discover cycles.[br]
+## [br]
+## A test is considered moved when:[br]
+## - It has the same name[br]
+## - But a different line number[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_moved_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ for discovered_test in discovered_tests:
+ # lookup in cache
+ var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_moved.bind(discovered_test))
+ for test in original_tests:
+ # update the line_number
+ var line_number_before := test.line_number
+ test.line_number = discovered_test.line_number
+ GdUnitSignals.instance().gdunit_test_discover_modified.emit(test)
+ if _is_debug:
+ prints("-> moved test id:%s %s: line:(%d -> %d)" % [test.guid, test.display_name, line_number_before, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes renamed tests between discover cycles.[br]
+## [br]
+## A test is considered renamed when:[br]
+## - It has the same line number[br]
+## - But a different name[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_renamed_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ for discovered_test in discovered_tests:
+ # lookup in cache
+ var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_renamed.bind(discovered_test))
+ for test in original_tests:
+ # update the renaming names
+ var original_display_name := test.display_name
+ test.test_name = discovered_test.test_name
+ test.display_name = discovered_test.display_name
+ GdUnitSignals.instance().gdunit_test_discover_modified.emit(test)
+ if _is_debug:
+ prints("-> renamed test id:%s %s -> %s" % [test.guid, original_display_name, test.display_name])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes deleted tests between discover cycles.[br]
+## [br]
+## A test is considered deleted when:[br]
+## - It exists in the cache[br]
+## - But is not found in the newly discovered tests[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_deleted_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ # lookup in cache
+ for test in cache:
+ if not discovered_tests.any(test_equals.bind(test)):
+ GdUnitSignals.instance().gdunit_test_discover_deleted.emit(test)
+ if _is_debug:
+ prints("-> deleted test id:%s %s:%d" % [test.guid, test.display_name, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("deleted_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes newly added tests between discover cycles.[br]
+## [br]
+## A test is considered added when:[br]
+## - It exists in the newly discovered tests[br]
+## - But is not found in the cache[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests[br]
+## [param discover_sink] Callback to handle newly discovered tests
+func sync_added_tests(source_file: String, discovered_tests: Array[GdUnitTestCase], discover_sink: Callable) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ # lookup in cache
+ for test in discovered_tests:
+ if not cache.any(test_equals.bind(test)):
+ discover_sink.call(test)
+ if _is_debug:
+ prints("-> added test id:%s %s:%d" % [test.guid, test.display_name, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("added_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+func is_test_renamed(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.line_number == right.line_number and left.test_name != right.test_name
+
+
+func is_test_moved(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.line_number != right.line_number and left.test_name == right.test_name
+
+
+func test_equals(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.display_name == right.display_name
+
+
+# do rebuild the entire project, there is actual no way to enforce the Godot engine itself to do this
+func rebuild_project(script: Script) -> void:
+ var class_path := ProjectSettings.globalize_path(script.resource_path)
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard: CSharpScript change detected on: '%s' [/color]" % class_path)
+ var scene_tree := Engine.get_main_loop() as SceneTree
+ await scene_tree.process_frame
+
+ var output := []
+ var exit_code := OS.execute("dotnet", ["--version"], output)
+ if exit_code == -1:
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Rebuild the project failed.[/color]")
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Can't find installed `dotnet`! Please check your environment is setup correctly.[/color]")
+ return
+
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Found dotnet v%s[/color]" % str(output[0]).strip_edges())
+ output.clear()
+
+ exit_code = OS.execute("dotnet", ["build"], output)
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Rebuild the project ... [/color]")
+ for out: String in output:
+ print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges())
+ await scene_tree.process_frame
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid
new file mode 100644
index 0000000..0d8b757
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid
@@ -0,0 +1 @@
+uid://dnyw8fm7vfc6i
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd
new file mode 100644
index 0000000..5d0e5b6
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd
@@ -0,0 +1,13 @@
+## A static utility class that acts as a central sink for test case discovery events in GdUnit4.
+## Instead of implementing custom sink classes, test discovery consumers should connect to
+## the GdUnitSignals.gdunit_test_discovered signal to receive test case discoveries.
+## This design allows for a more flexible and decoupled test discovery system.
+class_name GdUnitTestDiscoverSink
+extends RefCounted
+
+
+## Emits a discovered test case through the GdUnitSignals system.[br]
+## Sends the test case to all listeners connected to the gdunit_test_discovered signal.[br]
+## [member test_case] The discovered test case to be broadcast to all connected listeners.
+static func discover(test_case: GdUnitTestCase) -> void:
+ GdUnitSignals.instance().gdunit_test_discover_added.emit(test_case)
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid
new file mode 100644
index 0000000..841f240
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid
@@ -0,0 +1 @@
+uid://jf2a2cjlqkkr
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
new file mode 100644
index 0000000..cb3e407
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
@@ -0,0 +1,171 @@
+class_name GdUnitTestDiscoverer
+extends RefCounted
+
+
+static func run() -> Array[GdUnitTestCase]:
+ console_log("Running test discovery ..")
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+
+ # We run the test discovery in an extra thread so that the main thread is not blocked
+ var t:= Thread.new()
+ @warning_ignore("return_value_discarded")
+ t.start(func () -> Array[GdUnitTestCase]:
+ # Loading previous test session
+ var runner_config := GdUnitRunnerConfig.new()
+ runner_config.load_config()
+ var recovered_tests := runner_config.test_cases()
+ var test_suite_directories := scan_all_test_directories(GdUnitSettings.test_root_folder())
+ var scanner := GdUnitTestSuiteScanner.new()
+
+ var collected_tests: Array[GdUnitTestCase] = []
+ var collected_test_suites: Array[Script] = []
+ # collect test suites
+ for test_suite_dir in test_suite_directories:
+ collected_test_suites.append_array(scanner.scan_directory(test_suite_dir))
+
+ # Do sync the main thread before emit the discovered test suites to the inspector
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ for test_suites_script in collected_test_suites:
+ discover_tests(test_suites_script, func(test_case: GdUnitTestCase) -> void:
+ # Sync test uid from last test session
+ recover_test_guid(test_case, recovered_tests)
+ collected_tests.append(test_case)
+ GdUnitTestDiscoverSink.discover(test_case)
+ )
+
+ console_log_discover_results(collected_tests)
+ if !recovered_tests.is_empty():
+ console_log("Recovered last test session successfully, %d tests restored." % recovered_tests.size(), true)
+ return collected_tests
+ )
+ # wait unblocked to the tread is finished
+ while t.is_alive():
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ # needs finally to wait for finish
+ var test_to_execute: Array[GdUnitTestCase] = await t.wait_to_finish()
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ return test_to_execute
+
+
+## Restores the last test run session by loading the test run config file and rediscover the tests
+static func restore_last_session() -> void:
+ if GdUnitSettings.is_test_discover_enabled():
+ return
+
+ var runner_config := GdUnitRunnerConfig.new()
+ var result := runner_config.load_config()
+ # Report possible config loading errors
+ if result.is_error():
+ console_log("Recovery of the last test session failed: %s" % result.error_message(), true)
+ # If no config file found, skip test recovery
+ if result.is_warn():
+ return
+
+ # If no tests recorded, skip test recovery
+ var test_cases := runner_config.test_cases()
+ if test_cases.size() == 0:
+ return
+
+ # We run the test session restoring in an extra thread so that the main thread is not blocked
+ var t:= Thread.new()
+ t.start(func () -> void:
+ # Do sync the main thread before emit the discovered test suites to the inspector
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ console_log("Recovering last test session ..", true)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+ for test_case in test_cases:
+ GdUnitTestDiscoverSink.discover(test_case)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ console_log("Recovered last test session successfully, %d tests restored." % test_cases.size(), true)
+ )
+ t.wait_to_finish()
+
+
+static func recover_test_guid(current: GdUnitTestCase, recovered_tests: Array[GdUnitTestCase]) -> void:
+ for recovered_test in recovered_tests:
+ if recovered_test.fully_qualified_name == current.fully_qualified_name:
+ current.guid = recovered_test.guid
+
+
+static func console_log_discover_results(tests: Array[GdUnitTestCase]) -> void:
+ var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
+ return test.source_file
+ )
+ for suite_tests: Array in grouped_by_suites.values():
+ var test_case: GdUnitTestCase = suite_tests[0]
+ console_log("Discover: TestSuite %s with %d tests found" % [test_case.source_file, suite_tests.size()])
+ console_log("Discover tests done, %d TestSuites and total %d Tests found. " % [grouped_by_suites.size(), tests.size()])
+ console_log("")
+
+
+static func console_log(message: String, on_console := false) -> void:
+ prints(message)
+ if on_console:
+ GdUnitSignals.instance().gdunit_message.emit(message)
+
+
+static func filter_tests(method: Dictionary) -> bool:
+ var method_name: String = method["name"]
+ return method_name.begins_with("test_")
+
+
+static func default_discover_sink(test_case: GdUnitTestCase) -> void:
+ GdUnitTestDiscoverSink.discover(test_case)
+
+
+static func discover_tests(source_script: Script, discover_sink := default_discover_sink) -> void:
+ if source_script is GDScript:
+ var test_names := source_script.get_script_method_list()\
+ .filter(filter_tests)\
+ .map(func(method: Dictionary) -> String: return method["name"])
+ # no tests discovered?
+ if test_names.is_empty():
+ return
+
+ var parser := GdScriptParser.new()
+ var fds := parser.get_function_descriptors(source_script as GDScript, test_names)
+ for fd in fds:
+ var resolver := GdFunctionParameterSetResolver.new(fd)
+ for test_case in resolver.resolve_test_cases(source_script as GDScript):
+ discover_sink.call(test_case)
+ elif source_script.get_class() == "CSharpScript":
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return
+ for test_case in GdUnit4CSharpApiLoader.discover_tests(source_script):
+ discover_sink.call(test_case)
+
+
+static func scan_all_test_directories(root: String) -> PackedStringArray:
+ var base_directory := "res://"
+ # If the test root folder is configured as blank, "/", or "res://", use the root folder as described in the settings panel
+ if root.is_empty() or root == "/" or root == base_directory:
+ return [base_directory]
+ return scan_test_directories(base_directory, root, [])
+
+
+static func scan_test_directories(base_directory: String, test_directory: String, test_suite_paths: PackedStringArray) -> PackedStringArray:
+ print_verbose("Scannning for test directory '%s' at %s" % [test_directory, base_directory])
+ for directory in DirAccess.get_directories_at(base_directory):
+ if directory.begins_with("."):
+ continue
+ var current_directory := normalize_path(base_directory + "/" + directory)
+ if FileAccess.file_exists(current_directory + "/.gdignore"):
+ continue
+ if GdUnitTestSuiteScanner.exclude_scan_directories.has(current_directory):
+ continue
+ if match_test_directory(directory, test_directory):
+ @warning_ignore("return_value_discarded")
+ test_suite_paths.append(current_directory)
+ else:
+ @warning_ignore("return_value_discarded")
+ scan_test_directories(current_directory, test_directory, test_suite_paths)
+ return test_suite_paths
+
+
+static func normalize_path(path: String) -> String:
+ return path.replace("///", "//")
+
+
+static func match_test_directory(directory: String, test_directory: String) -> bool:
+ return directory == test_directory or test_directory.is_empty() or test_directory == "/" or test_directory == "res://"
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid
new file mode 100644
index 0000000..c505a79
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid
@@ -0,0 +1 @@
+uid://dr8h7h5duuybc