making sure the issue comes from GDUnit addon folder
All checks were successful
Create tag and build when new code gets to main / Export (push) Successful in 7m6s

This commit is contained in:
2026-01-26 08:51:14 +01:00
parent 51907a1f01
commit 72bf3d4cc5
464 changed files with 6493 additions and 0 deletions

View File

View File

View File

View File

View File

@@ -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]

View File

@@ -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

View File

View File

@@ -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")

View File

@@ -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 "-<Unexpected>-"
static func resolve_test_suite_path(source_script_path: String, test_root_folder: String = "test") -> String:
var file_name := source_script_path.get_basename().get_file()
var suite_name := _to_naming_convention(file_name)
if test_root_folder.is_empty() or test_root_folder == "/":
return source_script_path.replace(file_name, suite_name)
# is user tmp
if source_script_path.begins_with("user://tmp"):
return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name)
# at first look up is the script under a "src" folder located
var test_suite_path: String
var src_folder := source_script_path.find("/src/")
if src_folder != -1:
test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/")
else:
var paths := source_script_path.split("/", false)
# is a plugin script?
if paths[1] == "addons":
test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder]
# rebuild plugin path
for index in range(3, paths.size()):
test_suite_path += "/" + paths[index]
else:
test_suite_path = paths[0] + "//" + test_root_folder
for index in range(1, paths.size()):
test_suite_path += "/" + paths[index]
return normalize_path(test_suite_path).replace(file_name, suite_name)
static func normalize_path(path: String) -> String:
return path.replace("///", "/")
static func create_test_suite(test_suite_path: String, source_path: String) -> GdUnitResult:
# create directory if not exists
if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()):
var error_ := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir())
if error_ != OK:
return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error_])
var script := GDScript.new()
script.source_code = GdUnitTestSuiteTemplate.build_template(source_path)
var error := ResourceSaver.save(script, test_suite_path)
if error != OK:
return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error])
return GdUnitResult.success(test_suite_path)
static func get_test_case_line_number(resource_path: String, func_name: String) -> int:
var file := FileAccess.open(resource_path, FileAccess.READ)
if file != null:
var line_number := 0
while not file.eof_reached():
var row := file.get_line()
line_number += 1
# ignore comments and empty lines and not test functions
if row.begins_with("#") || row.length() == 0 || row.find("func test_") == -1:
continue
# abort if test case name found
if row.find("func") != -1 and row.find("test_" + func_name) != -1:
return line_number
return -1
func get_extends_classname(resource_path: String) -> String:
var file := FileAccess.open(resource_path, FileAccess.READ)
if file != null:
while not file.eof_reached():
var row := file.get_line()
# skip comments and empty lines
if row.begins_with("#") || row.length() == 0:
continue
# Stop at first function
if row.contains("func"):
return ""
var result := _regex_extends_clazz_name.search(row)
if result != null:
return result.get_string(1)
return ""
static func add_test_case(resource_path: String, func_name: String) -> GdUnitResult:
var script := load_with_disabled_warnings(resource_path)
# count all exiting lines and add two as space to add new test case
var line_number := count_lines(script) + 2
var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name)
if Engine.is_editor_hint():
# NOTE: Avoid using EditorInterface and EditorSettings directly,
# as it causes compilation errors in exported projects.
@warning_ignore_start("unsafe_method_access")
var editor_interface: Object = Engine.get_singleton("EditorInterface")
var settings: Object = editor_interface.get_editor_settings()
var ident_type: int = settings.get_setting("text_editor/behavior/indent/type")
var ident_size: int = settings.get_setting("text_editor/behavior/indent/size")
@warning_ignore_restore("unsafe_method_access")
if ident_type == 1:
func_body = func_body.replace(" ", "".lpad(ident_size, " "))
script.source_code += func_body
var error := ResourceSaver.save(script, resource_path)
if error != OK:
return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error])
return GdUnitResult.success({ "path" : resource_path, "line" : line_number})
static func count_lines(script: Script) -> int:
return script.source_code.split("\n").size()
static func test_suite_exists(test_suite_path: String) -> bool:
return FileAccess.file_exists(test_suite_path)
static func test_case_exists(test_suite_path :String, func_name :String) -> bool:
if not test_suite_exists(test_suite_path):
return false
var script := load_with_disabled_warnings(test_suite_path)
for f in script.get_script_method_list():
if f["name"] == "test_" + func_name:
return true
return false
static func create_test_case(test_suite_path: String, func_name: String, source_script_path: String) -> GdUnitResult:
if test_case_exists(test_suite_path, func_name):
var line_number := get_test_case_line_number(test_suite_path, func_name)
return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number})
if not test_suite_exists(test_suite_path):
var result := create_test_suite(test_suite_path, source_script_path)
if result.is_error():
return result
return add_test_case(test_suite_path, func_name)

View File

View File

View File

View File

View File

View File

@@ -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")

View File

@@ -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

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