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
All checks were successful
Create tag and build when new code gets to main / Export (push) Successful in 7m6s
This commit is contained in:
404
addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
Normal file
404
addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
Normal 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)
|
||||
Reference in New Issue
Block a user