summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
diff options
context:
space:
mode:
Diffstat (limited to 'addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd')
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd410
1 files changed, 410 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
new file mode 100644
index 0000000..345b7ed
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
@@ -0,0 +1,410 @@
+class_name GdUnitTestSuiteScanner
+extends RefCounted
+
+const TEST_FUNC_TEMPLATE ="""
+
+func test_${func_name}() -> void:
+ # remove this line and complete your test
+ assert_not_yet_implemented()
+"""
+
+
+# we exclude the gdunit source directorys by default
+const exclude_scan_directories = [
+ "res://addons/gdUnit4/bin",
+ "res://addons/gdUnit4/src",
+ "res://reports"]
+
+
+const ARGUMENT_TIMEOUT := "timeout"
+const ARGUMENT_SKIP := "do_skip"
+const ARGUMENT_SKIP_REASON := "skip_reason"
+const ARGUMENT_PARAMETER_SET := "test_parameters"
+
+
+var _script_parser := GdScriptParser.new()
+var _included_resources: PackedStringArray = []
+var _excluded_resources: PackedStringArray = []
+var _expression_runner := GdUnitExpressionRunner.new()
+var _regex_extends_clazz_name := RegEx.create_from_string("extends[\\s]+([\\S]+)")
+
+
+func prescan_testsuite_classes() -> void:
+ # scan and cache extends GdUnitTestSuite by class name an resource paths
+ var script_classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
+ for script_meta in script_classes:
+ var base_class: String = script_meta["base"]
+ var resource_path: String = script_meta["path"]
+ if base_class == "GdUnitTestSuite":
+ @warning_ignore("return_value_discarded")
+ _included_resources.append(resource_path)
+ elif ClassDB.class_exists(base_class):
+ @warning_ignore("return_value_discarded")
+ _excluded_resources.append(resource_path)
+
+
+func scan(resource_path: String) -> Array[Script]:
+ prescan_testsuite_classes()
+ # if single testsuite requested
+ if FileAccess.file_exists(resource_path):
+ var test_suite := _load_is_test_suite(resource_path)
+ if test_suite != null:
+ return [test_suite]
+ return []
+ return scan_directory(resource_path)
+
+
+func scan_directory(resource_path: String) -> Array[Script]:
+ prescan_testsuite_classes()
+ # We use the global cache to fast scan for test suites.
+ if _excluded_resources.has(resource_path):
+ return []
+
+ var base_dir := DirAccess.open(resource_path)
+ if base_dir == null:
+ prints("Given directory or file does not exists:", resource_path)
+ return []
+
+ prints("Scanning for test suites in:", resource_path)
+ return _scan_test_suites_scripts(base_dir, [])
+
+
+func _scan_test_suites_scripts(dir: DirAccess, collected_suites: Array[Script]) -> Array[Script]:
+ # Skip excluded directories
+ if dir.file_exists(".gdignore"):
+ prints("Exclude directory %s, containing .gdignore file" % dir.get_current_dir())
+ return []
+
+ if exclude_scan_directories.has(dir.get_current_dir()):
+ return collected_suites
+
+ var err := dir.list_dir_begin()
+ if err != OK:
+ push_error("Error on scanning directory %s" % dir.get_current_dir(), error_string(err))
+ return collected_suites
+ var file_name := dir.get_next()
+ while file_name != "":
+ var resource_path := GdUnitTestSuiteScanner._file(dir, file_name)
+ if dir.current_is_dir():
+ var sub_dir := DirAccess.open(resource_path)
+ if sub_dir != null:
+ @warning_ignore("return_value_discarded")
+ _scan_test_suites_scripts(sub_dir, collected_suites)
+ else:
+ var time := LocalTime.now()
+ var test_suite := _load_is_test_suite(resource_path)
+ if test_suite:
+ collected_suites.append(test_suite)
+ if OS.is_stdout_verbose() and time.elapsed_since_ms() > 300:
+ push_warning("Scanning of test-suite '%s' took more than 300ms: " % resource_path, time.elapsed_since())
+ file_name = dir.get_next()
+ return collected_suites
+
+
+static func _file(dir: DirAccess, file_name: String) -> String:
+ var current_dir := dir.get_current_dir()
+ if current_dir.ends_with("/"):
+ return current_dir + file_name
+ return current_dir + "/" + file_name
+
+
+func _load_is_test_suite(resource_path: String) -> Script:
+ if not GdUnitTestSuiteScanner._is_script_format_supported(resource_path):
+ return null
+
+ # We use the global cache to fast scan for test suites.
+ if _excluded_resources.has(resource_path):
+ return null
+ # Check in the global class cache whether the GdUnitTestSuite class has been extended.
+ if _included_resources.has(resource_path):
+ return GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path)
+
+ # Otherwise we need to scan manual, we need to exclude classes where direct extends form Godot classes
+ # the resource loader can fail to load e.g. plugin classes with do preload other scripts
+ #var extends_from := get_extends_classname(resource_path)
+ # If not extends is defined or extends from a Godot class
+ #if extends_from.is_empty() or ClassDB.class_exists(extends_from):
+ # return null
+ # Finally, we need to load the class to determine it is a test suite
+ var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path)
+ if not is_test_suite(script):
+ return null
+ return script
+
+
+func load_suite(script: GDScript, tests: Array[GdUnitTestCase]) -> GdUnitTestSuite:
+ var test_suite: GdUnitTestSuite = script.new()
+ var first_test: GdUnitTestCase = tests.front()
+ test_suite.set_name(first_test.suite_name)
+
+ # We need to group first all parameterized tests together to load the parameter set once
+ var grouped_by_test := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
+ return test.test_name
+ )
+ # Extract function descriptors
+ var test_names: PackedStringArray = grouped_by_test.keys()
+ test_names.append("before")
+ var function_descriptors := _script_parser.get_function_descriptors(script, test_names)
+
+ # Convert to test
+ for fd in function_descriptors:
+ if fd.name() == "before":
+ _handle_test_suite_arguments(test_suite, script, fd)
+ continue
+
+ # Build test attributes from test method
+ var test_attribute := _build_test_attribute(script, fd)
+ # Create test from descriptor and given attributes
+ var test_group: Array = grouped_by_test[fd.name()]
+ for test: GdUnitTestCase in test_group:
+ # We need a copy, because of mutable state
+ var attribute: TestCaseAttribute = test_attribute.clone()
+ test_suite.add_child(_TestCase.new(test, attribute, fd))
+ return test_suite
+
+
+func _build_test_attribute(script: GDScript, fd: GdFunctionDescriptor) -> TestCaseAttribute:
+ var collected_unknown_aruments := PackedStringArray()
+ var attribute := TestCaseAttribute.new()
+
+ # Collect test attributes
+ for arg: GdFunctionArgument in fd.args():
+ if arg.type() == GdObjects.TYPE_FUZZER:
+ attribute.fuzzers.append(arg)
+ else:
+ # We allow underscore as prefix to prevent unused argument warnings
+ match arg.name().trim_prefix("_"):
+ ARGUMENT_TIMEOUT:
+ attribute.timeout = type_convert(arg.default(), TYPE_INT)
+ ARGUMENT_SKIP:
+ var result: Variant = _expression_runner.execute(script, arg.plain_value())
+ if result is bool:
+ attribute.is_skipped = result
+ else:
+ push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value())
+ ARGUMENT_SKIP_REASON:
+ attribute.skip_reason = arg.plain_value()
+ Fuzzer.ARGUMENT_ITERATIONS:
+ attribute.fuzzer_iterations = type_convert(arg.default(), TYPE_INT)
+ Fuzzer.ARGUMENT_SEED:
+ attribute.test_seed = type_convert(arg.default(), TYPE_INT)
+ ARGUMENT_PARAMETER_SET:
+ collected_unknown_aruments.clear()
+ pass
+ _:
+ collected_unknown_aruments.append(arg.name())
+
+ # Verify for unknown arguments
+ if not collected_unknown_aruments.is_empty():
+ attribute.is_skipped = true
+ attribute.skip_reason = "Unknown test case argument's %s found." % collected_unknown_aruments
+
+ return attribute
+
+
+# We load the test suites with disabled unsafe_method_access to avoid spamming loading errors
+# `unsafe_method_access` will happen when using `assert_that`
+static func load_with_disabled_warnings(resource_path: String) -> Script:
+ # grap current level
+ var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access")
+
+ # disable and load the script
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0)
+
+ var script: Script = (
+ GdUnitTestResourceLoader.load_gd_script(resource_path) if resource_path.ends_with("resource")
+ else ResourceLoader.load(resource_path))
+
+ # restore
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access)
+ return script
+
+
+static func is_test_suite(script: Script) -> bool:
+ if script is GDScript:
+ var stack := [script]
+ while not stack.is_empty():
+ var current: Script = stack.pop_front()
+ var base: Script = current.get_base_script()
+ if base != null:
+ if base.resource_path.find("GdUnitTestSuite") != -1:
+ return true
+ stack.push_back(base)
+ elif script != null and script.get_class() == "CSharpScript":
+ return true
+ return false
+
+
+static func _is_script_format_supported(resource_path: String) -> bool:
+ var ext := resource_path.get_extension()
+ return ext == "gd" or ext == "cs"
+
+
+static func parse_test_suite_name(script: Script) -> String:
+ return script.resource_path.get_file().replace(".gd", "")
+
+
+func _handle_test_suite_arguments(test_suite: GdUnitTestSuite, script: GDScript, fd: GdFunctionDescriptor) -> void:
+ for arg in fd.args():
+ # We allow underscore as prefix to prevent unused argument warnings
+ match arg.name().trim_prefix("_"):
+ ARGUMENT_SKIP:
+ var result: Variant = _expression_runner.execute(script, arg.plain_value())
+ if result is bool:
+ test_suite.__is_skipped = result
+ else:
+ push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value())
+ ARGUMENT_SKIP_REASON:
+ test_suite.__skip_reason = arg.plain_value()
+ _:
+ push_error("Unsuported argument `%s` found on before() at '%s'!" % [arg.name(), script.resource_path])
+
+
+# converts given file name by configured naming convention
+static func _to_naming_convention(file_name: String) -> String:
+ var nc :int = GdUnitSettings.get_setting(GdUnitSettings.TEST_SUITE_NAMING_CONVENTION, 0)
+ match nc:
+ GdUnitSettings.NAMING_CONVENTIONS.AUTO_DETECT:
+ if GdObjects.is_snake_case(file_name):
+ return GdObjects.to_snake_case(file_name + "Test")
+ return GdObjects.to_pascal_case(file_name + "Test")
+ GdUnitSettings.NAMING_CONVENTIONS.SNAKE_CASE:
+ return GdObjects.to_snake_case(file_name + "Test")
+ GdUnitSettings.NAMING_CONVENTIONS.PASCAL_CASE:
+ return GdObjects.to_pascal_case(file_name + "Test")
+ push_error("Unexpected case")
+ return "-<Unexpected>-"
+
+
+static func resolve_test_suite_path(source_script_path: String, test_root_folder: String = "test") -> String:
+ var file_name := source_script_path.get_basename().get_file()
+ var suite_name := _to_naming_convention(file_name)
+ if test_root_folder.is_empty() or test_root_folder == "/":
+ return source_script_path.replace(file_name, suite_name)
+
+ # is user tmp
+ if source_script_path.begins_with("user://tmp"):
+ return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name)
+
+ # at first look up is the script under a "src" folder located
+ var test_suite_path: String
+ var src_folder := source_script_path.find("/src/")
+ if src_folder != -1:
+ test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/")
+ else:
+ var paths := source_script_path.split("/", false)
+ # is a plugin script?
+ if paths[1] == "addons":
+ test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder]
+ # rebuild plugin path
+ for index in range(3, paths.size()):
+ test_suite_path += "/" + paths[index]
+ else:
+ test_suite_path = paths[0] + "//" + test_root_folder
+ for index in range(1, paths.size()):
+ test_suite_path += "/" + paths[index]
+ return normalize_path(test_suite_path).replace(file_name, suite_name)
+
+
+static func normalize_path(path: String) -> String:
+ return path.replace("///", "/")
+
+
+static func create_test_suite(test_suite_path: String, source_path: String) -> GdUnitResult:
+ # create directory if not exists
+ if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()):
+ var error_ := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir())
+ if error_ != OK:
+ return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error_])
+ var script := GDScript.new()
+ script.source_code = GdUnitTestSuiteTemplate.build_template(source_path)
+ var error := ResourceSaver.save(script, test_suite_path)
+ if error != OK:
+ return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error])
+ return GdUnitResult.success(test_suite_path)
+
+
+static func get_test_case_line_number(resource_path: String, func_name: String) -> int:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file != null:
+ var line_number := 0
+ while not file.eof_reached():
+ var row := file.get_line()
+ line_number += 1
+ # ignore comments and empty lines and not test functions
+ if row.begins_with("#") || row.length() == 0 || row.find("func test_") == -1:
+ continue
+ # abort if test case name found
+ if row.find("func") != -1 and row.find("test_" + func_name) != -1:
+ return line_number
+ return -1
+
+
+func get_extends_classname(resource_path: String) -> String:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file != null:
+ while not file.eof_reached():
+ var row := file.get_line()
+ # skip comments and empty lines
+ if row.begins_with("#") || row.length() == 0:
+ continue
+ # Stop at first function
+ if row.contains("func"):
+ return ""
+ var result := _regex_extends_clazz_name.search(row)
+ if result != null:
+ return result.get_string(1)
+ return ""
+
+
+static func add_test_case(resource_path: String, func_name: String) -> GdUnitResult:
+ var script := load_with_disabled_warnings(resource_path)
+ # count all exiting lines and add two as space to add new test case
+ var line_number := count_lines(script) + 2
+ var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name)
+ if Engine.is_editor_hint():
+ # NOTE: Avoid using EditorInterface and EditorSettings directly,
+ # as it causes compilation errors in exported projects.
+ @warning_ignore_start("unsafe_method_access")
+ var editor_interface: Object = Engine.get_singleton("EditorInterface")
+ var settings: Object = editor_interface.get_editor_settings()
+ var ident_type: int = settings.get_setting("text_editor/behavior/indent/type")
+ var ident_size: int = settings.get_setting("text_editor/behavior/indent/size")
+ @warning_ignore_restore("unsafe_method_access")
+ if ident_type == 1:
+ func_body = func_body.replace(" ", "".lpad(ident_size, " "))
+ script.source_code += func_body
+ var error := ResourceSaver.save(script, resource_path)
+ if error != OK:
+ return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error])
+ return GdUnitResult.success({ "path" : resource_path, "line" : line_number})
+
+
+static func count_lines(script: Script) -> int:
+ return script.source_code.split("\n").size()
+
+
+static func test_suite_exists(test_suite_path: String) -> bool:
+ return FileAccess.file_exists(test_suite_path)
+
+
+static func test_case_exists(test_suite_path :String, func_name :String) -> bool:
+ if not test_suite_exists(test_suite_path):
+ return false
+ var script := load_with_disabled_warnings(test_suite_path)
+ for f in script.get_script_method_list():
+ if f["name"] == "test_" + func_name:
+ return true
+ return false
+
+
+static func create_test_case(test_suite_path: String, func_name: String, source_script_path: String) -> GdUnitResult:
+ if test_case_exists(test_suite_path, func_name):
+ var line_number := get_test_case_line_number(test_suite_path, func_name)
+ return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number})
+
+ if not test_suite_exists(test_suite_path):
+ var result := create_test_suite(test_suite_path, source_script_path)
+ if result.is_error():
+ return result
+ return add_test_case(test_suite_path, func_name)