diff options
| author | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
|---|---|---|
| committer | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
| commit | a89e744ed0e8c759caf041a580112365bbfe4303 (patch) | |
| tree | dd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src/core/parse | |
| parent | 6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff) | |
Diffstat (limited to 'addons/gdUnit4/src/core/parse')
16 files changed, 2006 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd new file mode 100644 index 0000000..fc83742 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd @@ -0,0 +1,25 @@ +class_name GdClassDescriptor +extends RefCounted + + +var _name :String +var _is_inner_class :bool +var _functions :Array[GdFunctionDescriptor] + + +func _init(p_name :String, p_is_inner_class :bool, p_functions :Array[GdFunctionDescriptor]) -> void: + _name = p_name + _is_inner_class = p_is_inner_class + _functions = p_functions + + +func name() -> String: + return _name + + +func is_inner_class() -> bool: + return _is_inner_class + + +func functions() -> Array[GdFunctionDescriptor]: + return _functions diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid new file mode 100644 index 0000000..759729e --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid @@ -0,0 +1 @@ +uid://baic1omvtg71r diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd new file mode 100644 index 0000000..f1b2244 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd @@ -0,0 +1,290 @@ +# holds all decodings for default values +class_name GdDefaultValueDecoder +extends GdUnitSingleton + + +@warning_ignore("unused_parameter") +var _decoders := { + TYPE_NIL: func(value :Variant) -> String: return "null", + TYPE_STRING: func(value :Variant) -> String: return '"%s"' % value, + TYPE_STRING_NAME: _on_type_StringName, + TYPE_BOOL: func(value :Variant) -> String: return str(value).to_lower(), + TYPE_FLOAT: func(value :Variant) -> String: return '%f' % value, + TYPE_COLOR: _on_type_Color, + TYPE_ARRAY: _on_type_Array.bind(TYPE_ARRAY), + TYPE_PACKED_BYTE_ARRAY: _on_type_Array.bind(TYPE_PACKED_BYTE_ARRAY), + TYPE_PACKED_STRING_ARRAY: _on_type_Array.bind(TYPE_PACKED_STRING_ARRAY), + TYPE_PACKED_FLOAT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT32_ARRAY), + TYPE_PACKED_FLOAT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT64_ARRAY), + TYPE_PACKED_INT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT32_ARRAY), + TYPE_PACKED_INT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT64_ARRAY), + TYPE_PACKED_COLOR_ARRAY: _on_type_Array.bind(TYPE_PACKED_COLOR_ARRAY), + TYPE_PACKED_VECTOR2_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR2_ARRAY), + TYPE_PACKED_VECTOR3_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR3_ARRAY), + TYPE_PACKED_VECTOR4_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR4_ARRAY), + TYPE_DICTIONARY: _on_type_Dictionary, + TYPE_RID: _on_type_RID, + TYPE_NODE_PATH: _on_type_NodePath, + TYPE_VECTOR2: _on_type_Vector.bind(TYPE_VECTOR2), + TYPE_VECTOR2I: _on_type_Vector.bind(TYPE_VECTOR2I), + TYPE_VECTOR3: _on_type_Vector.bind(TYPE_VECTOR3), + TYPE_VECTOR3I: _on_type_Vector.bind(TYPE_VECTOR3I), + TYPE_VECTOR4: _on_type_Vector.bind(TYPE_VECTOR4), + TYPE_VECTOR4I: _on_type_Vector.bind(TYPE_VECTOR4I), + TYPE_RECT2: _on_type_Rect2, + TYPE_RECT2I: _on_type_Rect2i, + TYPE_PLANE: _on_type_Plane, + TYPE_QUATERNION: _on_type_Quaternion, + TYPE_AABB: _on_type_AABB, + TYPE_BASIS: _on_type_Basis, + TYPE_CALLABLE: _on_type_Callable, + TYPE_SIGNAL: _on_type_Signal, + TYPE_TRANSFORM2D: _on_type_Transform2D, + TYPE_TRANSFORM3D: _on_type_Transform3D, + TYPE_PROJECTION: _on_type_Projection, + TYPE_OBJECT: _on_type_Object +} + +static func _regex(pattern: String) -> RegEx: + var regex := RegEx.new() + var err := regex.compile(pattern) + if err != OK: + push_error("error '%s' checked pattern '%s'" % [err, pattern]) + return null + return regex + + +func get_decoder(type: int) -> Callable: + return _decoders.get(type, func(value :Variant) -> String: return '%s' % value) + + +func _on_type_StringName(value: StringName) -> String: + if value.is_empty(): + return 'StringName()' + return 'StringName("%s")' % value + + +func _on_type_Object(value: Variant, _type: int) -> String: + return str(value) + + +func _on_type_Color(color: Color) -> String: + if color == Color.BLACK: + return "Color()" + return "Color%s" % color + + +func _on_type_NodePath(path: NodePath) -> String: + if path.is_empty(): + return 'NodePath()' + return 'NodePath("%s")' % path + + +func _on_type_Callable(_cb: Callable) -> String: + return 'Callable()' + + +func _on_type_Signal(_s: Signal) -> String: + return 'Signal()' + + +func _on_type_Dictionary(dict: Dictionary) -> String: + if dict.is_empty(): + return '{}' + return str(dict) + + +func _on_type_Array(value: Variant, type: int) -> String: + match type: + TYPE_ARRAY: + return str(value) + + TYPE_PACKED_COLOR_ARRAY: + var colors := PackedStringArray() + for color: Color in value: + @warning_ignore("return_value_discarded") + colors.append(_on_type_Color(color)) + if colors.is_empty(): + return "PackedColorArray()" + return "PackedColorArray([%s])" % ", ".join(colors) + + TYPE_PACKED_VECTOR2_ARRAY: + var vectors := PackedStringArray() + for vector: Vector2 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR2)) + if vectors.is_empty(): + return "PackedVector2Array()" + return "PackedVector2Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR3_ARRAY: + var vectors := PackedStringArray() + for vector: Vector3 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR3)) + if vectors.is_empty(): + return "PackedVector3Array()" + return "PackedVector3Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR4_ARRAY: + var vectors := PackedStringArray() + for vector: Vector4 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR4)) + if vectors.is_empty(): + return "PackedVector4Array()" + return "PackedVector4Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_STRING_ARRAY: + var values := PackedStringArray() + for v: String in value: + @warning_ignore("return_value_discarded") + values.append('"%s"' % v) + if values.is_empty(): + return "PackedStringArray()" + return "PackedStringArray([%s])" % ", ".join(values) + + TYPE_PACKED_BYTE_ARRAY,\ + TYPE_PACKED_FLOAT32_ARRAY,\ + TYPE_PACKED_FLOAT64_ARRAY,\ + TYPE_PACKED_INT32_ARRAY,\ + TYPE_PACKED_INT64_ARRAY: + var vectors := PackedStringArray() + for vector: Variant in value: + @warning_ignore("return_value_discarded") + vectors.append(str(vector)) + if vectors.is_empty(): + return GdObjects.type_as_string(type) + "()" + return "%s([%s])" % [GdObjects.type_as_string(type), ", ".join(vectors)] + return "unknown array type %d" % type + + +func _on_type_Vector(value: Variant, type: int) -> String: + + if typeof(value) != type: + push_error("Internal Error: type missmatch detected for value '%s', expects type %s" % [value, type_string(type)]) + return "" + + match type: + TYPE_VECTOR2: + if value == Vector2(): + return "Vector2()" + return "Vector2%s" % value + TYPE_VECTOR2I: + if value == Vector2i(): + return "Vector2i()" + return "Vector2i%s" % value + TYPE_VECTOR3: + if value == Vector3(): + return "Vector3()" + return "Vector3%s" % value + TYPE_VECTOR3I: + if value == Vector3i(): + return "Vector3i()" + return "Vector3i%s" % value + TYPE_VECTOR4: + if value == Vector4(): + return "Vector4()" + return "Vector4%s" % value + TYPE_VECTOR4I: + if value == Vector4i(): + return "Vector4i()" + return "Vector4i%s" % value + return "unknown vector type %d" % type + + +func _on_type_Transform2D(transform: Transform2D) -> String: + if transform == Transform2D(): + return "Transform2D()" + return "Transform2D(Vector2%s, Vector2%s, Vector2%s)" % [transform.x, transform.y, transform.origin] + + +func _on_type_Transform3D(transform: Transform3D) -> String: + if transform == Transform3D(): + return "Transform3D()" + return "Transform3D(Vector3%s, Vector3%s, Vector3%s, Vector3%s)" % [transform.basis.x, transform.basis.y, transform.basis.z, transform.origin] + + +func _on_type_Projection(projection: Projection) -> String: + return "Projection(Vector4%s, Vector4%s, Vector4%s, Vector4%s)" % [projection.x, projection.y, projection.z, projection.w] + + +@warning_ignore("unused_parameter") +func _on_type_RID(value: RID) -> String: + return "RID()" + + +func _on_type_Rect2(rect: Rect2) -> String: + if rect == Rect2(): + return "Rect2()" + return "Rect2(Vector2%s, Vector2%s)" % [rect.position, rect.size] + + +func _on_type_Rect2i(rect: Variant) -> String: + if rect == Rect2i(): + return "Rect2i()" + return "Rect2i(Vector2i%s, Vector2i%s)" % [rect.position, rect.size] + + +func _on_type_Plane(plane: Plane) -> String: + if plane == Plane(): + return "Plane()" + return "Plane(%d, %d, %d, %d)" % [plane.x, plane.y, plane.z, plane.d] + + +func _on_type_Quaternion(quaternion: Quaternion) -> String: + if quaternion == Quaternion(): + return "Quaternion()" + return "Quaternion(%d, %d, %d, %d)" % [quaternion.x, quaternion.y, quaternion.z, quaternion.w] + + +func _on_type_AABB(aabb: AABB) -> String: + if aabb == AABB(): + return "AABB()" + return "AABB(Vector3%s, Vector3%s)" % [aabb.position, aabb.size] + + +func _on_type_Basis(basis: Basis) -> String: + if basis == Basis(): + return "Basis()" + return "Basis(Vector3%s, Vector3%s, Vector3%s)" % [basis.x, basis.y, basis.z] + + +static func decode(value: Variant) -> String: + var type := typeof(value) + @warning_ignore("unsafe_cast") + if GdArrayTools.is_type_array(type) and (value as Array).is_empty(): + return "<empty>" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func decode_typed(type: int, value: Variant) -> String: + if value == null: + return "null" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func _get_value_decoder(type: int) -> Callable: + var decoder: GdDefaultValueDecoder = instance( + "GdUnitDefaultValueDecoders", + func() -> GdDefaultValueDecoder: + return GdDefaultValueDecoder.new()) + return decoder.get_decoder(type) diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid new file mode 100644 index 0000000..0f8e9c1 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid @@ -0,0 +1 @@ +uid://dl0wf0ieoppil diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd new file mode 100644 index 0000000..bd38eb2 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd @@ -0,0 +1,208 @@ +class_name GdFunctionArgument +extends RefCounted + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const UNDEFINED: String = "<-NO_ARG->" +const ARG_PARAMETERIZED_TEST := ["test_parameters", "_test_parameters"] + +static var _fuzzer_regex: RegEx +static var _cleanup_leading_spaces: RegEx +static var _fix_comma_space: RegEx + +var _name: String +var _type: int +var _type_hint: int +var _default_value: Variant +var _parameter_sets: PackedStringArray = [] + + +func _init(p_name: String, p_type: int, value: Variant = UNDEFINED, p_type_hint: int = TYPE_NIL) -> void: + _init_static_variables() + _name = p_name + _type = p_type + _type_hint = p_type_hint + if value != null and p_name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameter_set(str(value)) + _default_value = value + # is argument a fuzzer? + if _type == TYPE_OBJECT and _fuzzer_regex.search(_name): + _type = GdObjects.TYPE_FUZZER + + +func _init_static_variables() -> void: + if _fuzzer_regex == null: + _fuzzer_regex = GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)") + _cleanup_leading_spaces = RegEx.create_from_string("(?m)^[ \t]+") + _fix_comma_space = RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""") + + +func name() -> String: + return _name + + +func default() -> Variant: + return type_convert(_default_value, _type) + + +func set_value(value: String) -> void: + # we onle need to apply default values for Objects, all others are provided by the method descriptor + if _type == GdObjects.TYPE_FUZZER: + _default_value = value + return + if _name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameter_set(value) + _default_value = value + return + + if _type == TYPE_NIL or _type == GdObjects.TYPE_VARIANT: + _type = _extract_value_type(value) + if _type == GdObjects.TYPE_VARIANT and _default_value == null: + _default_value = value + if _default_value == null: + match _type: + TYPE_DICTIONARY: + _default_value = as_dictionary(value) + TYPE_ARRAY: + _default_value = as_array(value) + GdObjects.TYPE_FUZZER: + _default_value = value + _: + _default_value = str_to_var(value) + # if converting fails assign the original value without converting + if _default_value == null and value != null: + _default_value = value + #prints("set default_value: ", _default_value, "with type %d" % _type, " from original: '%s'" % value) + + +func _extract_value_type(value: String) -> int: + if value != UNDEFINED: + if _fuzzer_regex.search(_name): + return GdObjects.TYPE_FUZZER + if value.rfind(")") == value.length()-1: + return GdObjects.TYPE_FUNC + return _type + + +func value_as_string() -> String: + if has_default(): + return GdDefaultValueDecoder.decode_typed(_type, _default_value) + return "" + + +func plain_value() -> Variant: + return _default_value + + +func type() -> int: + return _type + + +func type_hint() -> int: + return _type_hint + + +func has_default() -> bool: + return not is_same(_default_value, UNDEFINED) + + +func is_typed_array() -> bool: + return _type == TYPE_ARRAY and _type_hint != TYPE_NIL + + +func is_parameter_set() -> bool: + return _name in ARG_PARAMETERIZED_TEST + + +func parameter_sets() -> PackedStringArray: + return _parameter_sets + + +static func get_parameter_set(parameters :Array[GdFunctionArgument]) -> GdFunctionArgument: + for current in parameters: + if current != null and current.is_parameter_set(): + return current + return null + + +func _to_string() -> String: + var s := _name + if _type != TYPE_NIL: + s += ": " + GdObjects.type_as_string(_type) + if _type_hint != TYPE_NIL: + s += "[%s]" % GdObjects.type_as_string(_type_hint) + if has_default(): + s += "=" + value_as_string() + return s + + +func _parse_parameter_set(input :String) -> PackedStringArray: + if not input.contains("["): + return [] + + input = _cleanup_leading_spaces.sub(input, "", true) + input = input.replace("\n", "").strip_edges().trim_prefix("[").trim_suffix("]").trim_prefix("]") + var single_quote := false + var double_quote := false + var array_end := 0 + var current_index := 0 + var output :PackedStringArray = [] + var buf := input.to_utf8_buffer() + var collected_characters: = PackedByteArray() + var matched :bool = false + + for c in buf: + current_index += 1 + matched = current_index == buf.size() + @warning_ignore("return_value_discarded") + collected_characters.push_back(c) + + match c: + # ' ': ignore spaces between array elements + 32: if array_end == 0 and (not double_quote and not single_quote): + collected_characters.remove_at(collected_characters.size()-1) + # ',': step over array element seperator ',' + 44: if array_end == 0: + matched = true + collected_characters.remove_at(collected_characters.size()-1) + # '`': + 39: single_quote = !single_quote + # '"': + 34: if not single_quote: double_quote = !double_quote + # '[' + 91: if not double_quote and not single_quote: array_end +=1 # counts array open + # ']' + 93: if not double_quote and not single_quote: array_end -=1 # counts array closed + + # if array closed than collect the element + if matched: + var parameters := _fix_comma_space.sub(collected_characters.get_string_from_utf8(), ", ", true) + if not parameters.is_empty(): + @warning_ignore("return_value_discarded") + output.append(parameters) + collected_characters.clear() + matched = false + return output + + +## value converters + +func as_array(value: String) -> Array: + if value == "Array()" or value == "[]": + return [] + + if value.begins_with("Array("): + value = value.lstrip("Array(").rstrip(")") + if value.begins_with("["): + return str_to_var(value) + return [] + + +func as_dictionary(value: String) -> Dictionary: + if value == "Dictionary()": + return {} + if value.begins_with("Dictionary("): + value = value.lstrip("Dictionary(").rstrip(")") + if value.begins_with("{"): + return str_to_var(value) + return {} diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid new file mode 100644 index 0000000..b98b975 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid @@ -0,0 +1 @@ +uid://dtwu3dpeq6huv diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd new file mode 100644 index 0000000..7eae4b2 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd @@ -0,0 +1,286 @@ +class_name GdFunctionDescriptor +extends RefCounted + +var _is_virtual :bool +var _is_static :bool +var _is_engine :bool +var _is_coroutine :bool +var _name :String +var _source_path: String +var _line_number :int +var _return_type :int +var _return_class :String +var _args : Array[GdFunctionArgument] +var _varargs :Array[GdFunctionArgument] + + + +static func create(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor: + var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, false, false, p_return_type, "", p_args) + fd.enrich_file_info(p_source_path, p_source_line) + return fd + +static func create_static(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor: + var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, true, false, p_return_type, "", p_args) + fd.enrich_file_info(p_source_path, p_source_line) + return fd + + +func _init(p_name :String, + p_line_number :int, + p_is_virtual :bool, + p_is_static :bool, + p_is_engine :bool, + p_return_type :int, + p_return_class :String, + p_args : Array[GdFunctionArgument], + p_varargs :Array[GdFunctionArgument] = []) -> void: + _name = p_name + _line_number = p_line_number + _return_type = p_return_type + _return_class = p_return_class + _is_virtual = p_is_virtual + _is_static = p_is_static + _is_engine = p_is_engine + _is_coroutine = false + _args = p_args + _varargs = p_varargs + + +func with_return_class(clazz_name: String) -> GdFunctionDescriptor: + _return_class = clazz_name + return self + + +func name() -> String: + return _name + + +func source_path() -> String: + return _source_path + + +func line_number() -> int: + return _line_number + + +func is_virtual() -> bool: + return _is_virtual + + +func is_static() -> bool: + return _is_static + + +func is_engine() -> bool: + return _is_engine + + +func is_vararg() -> bool: + return not _varargs.is_empty() + + +func is_coroutine() -> bool: + return _is_coroutine + + +func is_parameterized() -> bool: + for current in _args: + var arg :GdFunctionArgument = current + if arg.name() in GdFunctionArgument.ARG_PARAMETERIZED_TEST: + return true + return false + + +func is_private() -> bool: + return name().begins_with("_") and not is_virtual() + + +func return_type() -> int: + return _return_type + + +func return_type_as_string() -> String: + if return_type() == TYPE_NIL: + return "void" + if (return_type() == TYPE_OBJECT or return_type() == GdObjects.TYPE_ENUM) and not _return_class.is_empty(): + return _return_class + return GdObjects.type_as_string(return_type()) + + +func set_argument_value(arg_name: String, value: String) -> void: + var argument: GdFunctionArgument = _args.filter(func(arg: GdFunctionArgument) -> bool: + return arg.name() == arg_name + ).front() + if argument != null: + argument.set_value(value) + + +func enrich_arguments(arguments: Array[Dictionary]) -> void: + for arg_index: int in arguments.size(): + var arg: Dictionary = arguments[arg_index] + if arg["type"] != GdObjects.TYPE_VARARG: + var arg_name: String = arg["name"] + var arg_value: String = arg["value"] + set_argument_value(arg_name, arg_value) + + +func enrich_file_info(p_source_path: String, p_line_number: int) -> void: + _source_path = p_source_path + _line_number = p_line_number + + +func args() -> Array[GdFunctionArgument]: + return _args + + +func varargs() -> Array[GdFunctionArgument]: + return _varargs + + +func typed_args() -> String: + var collect := PackedStringArray() + for arg in args(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + for arg in varargs(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + return ", ".join(collect) + + +func _to_string() -> String: + var fsignature := "virtual " if is_virtual() else "" + if _return_type == TYPE_NIL: + return fsignature + "[Line:%s] func %s(%s):" % [line_number(), name(), typed_args()] + var func_template := fsignature + "[Line:%s] func %s(%s) -> %s:" + if is_static(): + func_template= "[Line:%s] static func %s(%s) -> %s:" + return func_template % [line_number(), name(), typed_args(), return_type_as_string()] + + +# extract function description given by Object.get_method_list() +static func extract_from(descriptor :Dictionary, is_engine_ := true) -> GdFunctionDescriptor: + var func_name: String = descriptor["name"] + var function_flags: int = descriptor["flags"] + var return_descriptor: Dictionary = descriptor["return"] + var clazz_name: String = return_descriptor["class_name"] + var is_virtual_: bool = function_flags & METHOD_FLAG_VIRTUAL + var is_static_: bool = function_flags & METHOD_FLAG_STATIC + var is_vararg_: bool = function_flags & METHOD_FLAG_VARARG + + return GdFunctionDescriptor.new( + func_name, + -1, + is_virtual_, + is_static_, + is_engine_, + _extract_return_type(return_descriptor), + clazz_name, + _extract_args(descriptor), + _build_varargs(is_vararg_) + ) + +# temporary exclude GlobalScope enums +const enum_fix := [ + "Side", + "Corner", + "Orientation", + "ClockDirection", + "HorizontalAlignment", + "VerticalAlignment", + "InlineAlignment", + "EulerOrder", + "Error", + "Key", + "MIDIMessage", + "MouseButton", + "MouseButtonMask", + "JoyButton", + "JoyAxis", + "PropertyHint", + "PropertyUsageFlags", + "MethodFlags", + "Variant.Type", + "Control.LayoutMode"] + + +static func _extract_return_type(return_info :Dictionary) -> int: + var type :int = return_info["type"] + var usage :int = return_info["usage"] + if type == TYPE_INT and usage & PROPERTY_USAGE_CLASS_IS_ENUM: + return GdObjects.TYPE_ENUM + if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + if type == TYPE_NIL and usage == 6: + return GdObjects.TYPE_VOID + return type + + +static func _extract_args(descriptor :Dictionary) -> Array[GdFunctionArgument]: + var args_ :Array[GdFunctionArgument] = [] + var arguments :Array = descriptor["args"] + var defaults :Array = descriptor["default_args"] + # iterate backwards because the default values are stored from right to left + while not arguments.is_empty(): + var arg :Dictionary = arguments.pop_back() + var arg_name := _argument_name(arg) + var arg_type := _argument_type(arg) + var arg_type_hint := _argument_hint(arg) + #var arg_class: StringName = arg["class_name"] + var default_value: Variant = GdFunctionArgument.UNDEFINED if defaults.is_empty() else defaults.pop_back() + args_.push_front(GdFunctionArgument.new(arg_name, arg_type, default_value, arg_type_hint)) + return args_ + + +static func _build_varargs(p_is_vararg :bool) -> Array[GdFunctionArgument]: + var varargs_ :Array[GdFunctionArgument] = [] + if not p_is_vararg: + return varargs_ + varargs_.push_back(GdFunctionArgument.new("varargs", GdObjects.TYPE_VARARG, '')) + return varargs_ + + +static func _argument_name(arg :Dictionary) -> String: + return arg["name"] + + +static func _argument_type(arg :Dictionary) -> int: + var type :int = arg["type"] + var usage :int = arg["usage"] + + if type == TYPE_OBJECT: + if arg["class_name"] == "Node": + return GdObjects.TYPE_NODE + if arg["class_name"] == "Fuzzer": + return GdObjects.TYPE_FUZZER + + # if the argument untyped we need to scan the assignef value type + if type == TYPE_NIL and usage == PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + return type + + +static func _argument_hint(arg :Dictionary) -> int: + var hint :int = arg["hint"] + var hint_string :String = arg["hint_string"] + + match hint: + PROPERTY_HINT_ARRAY_TYPE: + return GdObjects.string_to_type(hint_string) + _: + return 0 + + +static func _argument_type_as_string(arg :Dictionary) -> String: + var type := _argument_type(arg) + match type: + TYPE_NIL: + return "" + TYPE_OBJECT: + var clazz_name :String = arg["class_name"] + if not clazz_name.is_empty(): + return clazz_name + return "" + _: + return GdObjects.type_as_string(type) diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid new file mode 100644 index 0000000..0396b95 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid @@ -0,0 +1 @@ +uid://csn6e1csw3fry diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd new file mode 100644 index 0000000..9c45e62 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd @@ -0,0 +1,188 @@ +class_name GdFunctionParameterSetResolver +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ParameterExtractor extends '${clazz_path}' + +func __extract_test_parameters() -> Array: + return ${test_params} + +""" + +const EXCLUDE_PROPERTIES_TO_COPY = [ + "script", + "type", + "Node", + "_import_path"] + + +var _fd: GdFunctionDescriptor +var _static_sets_by_index := {} +var _is_static := true + +func _init(fd: GdFunctionDescriptor) -> void: + _fd = fd + + +func resolve_test_cases(script: GDScript) -> Array[GdUnitTestCase]: + if not is_parameterized(): + return [GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name())] + return extract_test_cases_by_reflection(script) + + +func is_parameterized() -> bool: + return _fd.is_parameterized() + + +func is_parameter_sets_static() -> bool: + return _is_static + + +func is_parameter_set_static(index: int) -> bool: + return _is_static and _static_sets_by_index.get(index, false) + + +# validates the given arguments are complete and matches to required input fields of the test function +func validate(input_value_set: Array) -> String: + var input_arguments := _fd.args() + # check given parameter set with test case arguments + var expected_arg_count := input_arguments.size() - 1 + for input_values :Variant in input_value_set: + var parameter_set_index := input_value_set.find(input_values) + if input_values is Array: + var arr_values: Array = input_values + var current_arg_count := arr_values.size() + if current_arg_count != expected_arg_count: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n The test case requires [%d] input parameters, but the set contains [%d]" % [parameter_set_index, expected_arg_count, current_arg_count] + var error := validate_parameter_types(input_arguments, arr_values, parameter_set_index) + if not error.is_empty(): + return error + else: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n Expecting an array of input values." % parameter_set_index + return "" + + +static func validate_parameter_types(input_arguments: Array, input_values: Array, parameter_set_index: int) -> String: + for i in input_arguments.size(): + var input_param: GdFunctionArgument = input_arguments[i] + # only check the test input arguments + if input_param.is_parameter_set(): + continue + var input_param_type := input_param.type() + var input_value :Variant = input_values[i] + var input_value_type := typeof(input_value) + # input parameter is not typed or is Variant we skip the type test + if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT: + continue + # is input type enum allow int values + if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT: + continue + # allow only equal types and object == null + if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL: + continue + if input_param_type != input_value_type: + return "\n The parameter set at index [%d] does not match the expected input parameters!\n The value '%s' does not match the required input parameter <%s>." % [parameter_set_index, input_value, input_param] + return "" + + +func extract_test_cases_by_reflection(script: GDScript) -> Array[GdUnitTestCase]: + var source: Node = script.new() + source.queue_free() + + var fa := GdFunctionArgument.get_parameter_set(_fd.args()) + var parameter_sets := fa.parameter_sets() + # if no parameter set detected we need to resolve it by using reflection + if parameter_sets.size() == 0: + _is_static = false + return _extract_test_cases_by_reflection(source, script) + else: + var test_cases: Array[GdUnitTestCase] = [] + var property_names := _extract_property_names(source) + for parameter_set_index in parameter_sets.size(): + var parameter_set := parameter_sets[parameter_set_index] + _static_sets_by_index[parameter_set_index] = _is_static_parameter_set(parameter_set, property_names) + @warning_ignore("return_value_discarded") + test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), parameter_set_index, parameter_set)) + parameter_set_index += 1 + return test_cases + + +func _extract_property_names(source: Node) -> PackedStringArray: + return source.get_property_list()\ + .map(func(property :Dictionary) -> String: return property["name"])\ + .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property)) + + +# tests if the test property set contains an property reference by name, if not the parameter set holds only static values +func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool: + for property_name in property_names: + if parameters.contains(property_name): + _is_static = false + return false + return true + + +func _extract_test_cases_by_reflection(source: Node, script: GDScript) -> Array[GdUnitTestCase]: + var parameter_sets := load_parameter_sets(source) + var test_cases: Array[GdUnitTestCase] = [] + for index in parameter_sets.size(): + var parameter_set := str(parameter_sets[index]) + @warning_ignore("return_value_discarded") + test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), index, parameter_set)) + return test_cases + + +# extracts the arguments from the given test case, using kind of reflection solution +# to restore the parameters from a string representation to real instance type +func load_parameter_sets(source: Node) -> Array: + var source_script: GDScript = source.get_script() + var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args()) + var source_code := CLASS_TEMPLATE \ + .replace("${clazz_path}", source_script.resource_path) \ + .replace("${test_params}", parameter_arg.value_as_string()) + var script := GDScript.new() + script.source_code = source_code + # enable this lines only for debuging + #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name() + #DirAccess.remove_absolute(script.resource_path) + #ResourceSaver.save(script, script.resource_path) + var result := script.reload() + if result != OK: + push_error("Extracting test parameters failed! Script loading error: %s" % result) + return [] + var instance: Node = script.new() + GdFunctionParameterSetResolver.copy_properties(source, instance) + instance.queue_free() + var parameter_sets: Array = instance.call("__extract_test_parameters") + return fixure_typed_parameters(parameter_sets, _fd.args()) + + +func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array: + for parameter_set_index in parameter_sets.size(): + var parameter_set: Array = parameter_sets[parameter_set_index] + # run over all function arguments + for parameter_index in parameter_set.size(): + var parameter :Variant = parameter_set[parameter_index] + var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index] + if parameter is Array: + var as_array: Array = parameter + # we need to convert the untyped array to the expected typed version + if arg_descriptor.is_typed_array(): + parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null) + return parameter_sets + + +static func copy_properties(source: Object, dest: Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, "<empty>"); + continue + dest.set(property_name, property_value) diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid new file mode 100644 index 0000000..2d86843 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://ck3w7dlo43ky6 diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd new file mode 100644 index 0000000..a102508 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd @@ -0,0 +1,764 @@ +class_name GdScriptParser +extends RefCounted + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const TYPE_VOID = GdObjects.TYPE_VOID +const TYPE_VARIANT = GdObjects.TYPE_VARIANT +const TYPE_VARARG = GdObjects.TYPE_VARARG +const TYPE_FUNC = GdObjects.TYPE_FUNC +const TYPE_FUZZER = GdObjects.TYPE_FUZZER +const TYPE_ENUM = GdObjects.TYPE_ENUM + + +var TOKEN_NOT_MATCH := Token.new("") +var TOKEN_SPACE := SkippableToken.new(" ") +var TOKEN_TABULATOR := SkippableToken.new("\t") +var TOKEN_NEW_LINE := SkippableToken.new("\n") +var TOKEN_COMMENT := SkippableToken.new("#") +var TOKEN_CLASS_NAME := RegExToken.new("class_name", GdUnitTools.to_regex("(class_name)\\s+([\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class_name)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_INNER_CLASS := TokenInnerClass.new("class", GdUnitTools.to_regex("(class)\\s+(\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_EXTENDS := RegExToken.new("extends", GdUnitTools.to_regex("extends\\s+")) +var TOKEN_ENUM := RegExToken.new("enum", GdUnitTools.to_regex("enum\\s+")) +var TOKEN_FUNCTION_STATIC_DECLARATION := RegExToken.new("static func", GdUnitTools.to_regex("^static\\s+func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION_DECLARATION := RegExToken.new("func", GdUnitTools.to_regex("^func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION := Token.new(".") +var TOKEN_FUNCTION_RETURN_TYPE := Token.new("->") +var TOKEN_FUNCTION_END := Token.new("):") +var TOKEN_ARGUMENT_ASIGNMENT := Token.new("=") +var TOKEN_ARGUMENT_TYPE_ASIGNMENT := Token.new(":=") +var TOKEN_ARGUMENT_FUZZER := FuzzerToken.new(GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)")) +var TOKEN_ARGUMENT_TYPE := Token.new(":") +var TOKEN_ARGUMENT_VARIADIC := Token.new("...") +var TOKEN_ARGUMENT_SEPARATOR := Token.new(",") +var TOKEN_BRACKET_ROUND_OPEN := Token.new("(") +var TOKEN_BRACKET_ROUND_CLOSE := Token.new(")") +var TOKEN_BRACKET_SQUARE_OPEN := Token.new("[") +var TOKEN_BRACKET_SQUARE_CLOSE := Token.new("]") +var TOKEN_BRACKET_CURLY_OPEN := Token.new("{") +var TOKEN_BRACKET_CURLY_CLOSE := Token.new("}") + + +var OPERATOR_ADD := Operator.new("+") +var OPERATOR_SUB := Operator.new("-") +var OPERATOR_MUL := Operator.new("*") +var OPERATOR_DIV := Operator.new("/") +var OPERATOR_REMAINDER := Operator.new("%") + +var TOKENS :Array[Token] = [ + TOKEN_SPACE, + TOKEN_TABULATOR, + TOKEN_NEW_LINE, + TOKEN_COMMENT, + TOKEN_BRACKET_ROUND_OPEN, + TOKEN_BRACKET_ROUND_CLOSE, + TOKEN_BRACKET_SQUARE_OPEN, + TOKEN_BRACKET_SQUARE_CLOSE, + TOKEN_BRACKET_CURLY_OPEN, + TOKEN_BRACKET_CURLY_CLOSE, + TOKEN_CLASS_NAME, + TOKEN_INNER_CLASS, + TOKEN_EXTENDS, + TOKEN_ENUM, + TOKEN_FUNCTION_STATIC_DECLARATION, + TOKEN_FUNCTION_DECLARATION, + TOKEN_ARGUMENT_FUZZER, + TOKEN_ARGUMENT_TYPE_ASIGNMENT, + TOKEN_ARGUMENT_ASIGNMENT, + TOKEN_ARGUMENT_TYPE, + TOKEN_ARGUMENT_VARIADIC, + TOKEN_FUNCTION, + TOKEN_ARGUMENT_SEPARATOR, + TOKEN_FUNCTION_RETURN_TYPE, + OPERATOR_ADD, + OPERATOR_SUB, + OPERATOR_MUL, + OPERATOR_DIV, + OPERATOR_REMAINDER, +] + +var _regex_strip_comments := GdUnitTools.to_regex("^([^#\"']|'[^']*'|\"[^\"]*\")*\\K#.*") +var _scanned_inner_classes := PackedStringArray() +var _script_constants := {} +var _is_awaiting := GdUnitTools.to_regex("\\bawait\\s+(?![^\"]*\"[^\"]*$)(?!.*#.*await)") + + +static func to_unix_format(input :String) -> String: + return input.replace("\r\n", "\n") + + +class Token extends RefCounted: + var _token: String + var _consumed: int + var _is_operator: bool + + func _init(p_token: String, p_is_operator := false) -> void: + _token = p_token + _is_operator = p_is_operator + _consumed = p_token.length() + + func match(input: String, pos: int) -> bool: + return input.findn(_token, pos) == pos + + func value() -> Variant: + return _token + + func is_operator() -> bool: + return _is_operator + + func is_inner_class() -> bool: + return _token == "class" + + func is_variable() -> bool: + return false + + func is_token(token_name :String) -> bool: + return _token == token_name + + func is_skippable() -> bool: + return false + + func _to_string() -> String: + return "Token{" + _token + "}" + + +class Operator extends Token: + func _init(p_value: String) -> void: + super(p_value, true) + + func _to_string() -> String: + return "OperatorToken{%s}" % [_token] + + +# A skippable token, is just a placeholder like space or tabs +class SkippableToken extends Token: + + func _init(p_token: String) -> void: + super(p_token) + + func is_skippable() -> bool: + return true + + +# Token to parse function arguments +class Variable extends Token: + var _plain_value :String + var _typed_value :Variant + var _type :int = TYPE_NIL + + + func _init(p_value: String) -> void: + super(p_value) + _type = _scan_type(p_value) + _plain_value = p_value + _typed_value = _cast_to_type(p_value, _type) + + + func _scan_type(p_value: String) -> int: + if p_value.begins_with("\"") and p_value.ends_with("\""): + return TYPE_STRING + var type_ := GdObjects.string_to_type(p_value) + if type_ != TYPE_NIL: + return type_ + if p_value.is_valid_int(): + return TYPE_INT + if p_value.is_valid_float(): + return TYPE_FLOAT + if p_value.is_valid_hex_number(): + return TYPE_INT + return TYPE_OBJECT + + + func _cast_to_type(p_value :String, p_type: int) -> Variant: + match p_type: + TYPE_STRING: + return p_value#.substr(1, p_value.length() - 2) + TYPE_INT: + return p_value.to_int() + TYPE_FLOAT: + return p_value.to_float() + return p_value + + + func is_variable() -> bool: + return true + + + func type() -> int: + return _type + + + func value() -> Variant: + return _typed_value + + + func plain_value() -> String: + return _plain_value + + + func _to_string() -> String: + return "Variable{%s: %s : '%s'}" % [_plain_value, GdObjects.type_as_string(_type), _token] + + +class RegExToken extends Token: + var _regex: RegEx + var _extract_group_index: int + var _value := "" + + + func _init(token: String, regex: RegEx, extract_group_index: int = -1) -> void: + super(token, false) + _regex = regex + _extract_group_index = extract_group_index + + + func match(input: String, pos: int) -> bool: + var matching := _regex.search(input, pos) + if matching == null or pos != matching.get_start(): + return false + if _extract_group_index != -1: + _value = matching.get_string(_extract_group_index) + _consumed = matching.get_end() - matching.get_start() + return true + + + func value() -> String: + return _value + + +# Token to parse Fuzzers +class FuzzerToken extends RegExToken: + + + func _init(regex: RegEx) -> void: + super("fuzzer", regex, 1) + + + func name() -> String: + return value() + + + func type() -> int: + return GdObjects.TYPE_FUZZER + + + func _to_string() -> String: + return "FuzzerToken{%s: '%s'}" % [value(), _token] + + +class TokenInnerClass extends RegExToken: + var _content := PackedStringArray() + + + static func _strip_leading_spaces(input: String) -> String: + var characters := input.to_utf8_buffer() + while not characters.is_empty(): + if characters[0] != 0x20: + break + characters.remove_at(0) + return characters.get_string_from_utf8() + + + static func _consumed_bytes(row: String) -> int: + return row.replace(" ", "").replace(" ", "").length() + + + func _init(token: String, p_regex: RegEx, extract_group_index: int = -1) -> void: + super(token, p_regex, extract_group_index) + + + func is_class_name(clazz_name: String) -> bool: + return value() == clazz_name + + + func content() -> PackedStringArray: + return _content + + + @warning_ignore_start("return_value_discarded") + func parse(source_rows: PackedStringArray, offset: int) -> void: + # add class signature + _content.clear() + _content.append(source_rows[offset]) + # parse class content + for row_index in range(offset+1, source_rows.size()): + # scan until next non tab + var source_row := source_rows[row_index] + var row := TokenInnerClass._strip_leading_spaces(source_row) + if row.is_empty() or row.begins_with("\t") or row.begins_with("#"): + # fold all line to left by removing leading tabs and spaces + if source_row.begins_with("\t"): + source_row = source_row.trim_prefix("\t") + # refomat invalid empty lines + if source_row.dedent().is_empty(): + _content.append("") + else: + _content.append(source_row) + continue + break + _consumed += TokenInnerClass._consumed_bytes("".join(_content)) + @warning_ignore_restore("return_value_discarded") + + + func _to_string() -> String: + return "TokenInnerClass{%s}" % [value()] + + + +func get_token(input: String, current_index: int) -> Token: + for t in TOKENS: + if t.match(input, current_index): + return t + return TOKEN_NOT_MATCH + + +func next_token(input: String, current_index: int, ignore_tokens :Array[Token] = []) -> Token: + var token := TOKEN_NOT_MATCH + for t :Token in TOKENS.filter(func(t :Token) -> bool: return not ignore_tokens.has(t)): + + if t.match(input, current_index): + token = t + break + if token == OPERATOR_SUB: + token = tokenize_value(input, current_index, token) + if token == TOKEN_NOT_MATCH: + return tokenize_value(input, current_index, token, ignore_tokens.has(TOKEN_FUNCTION)) + return token + + +func tokenize_value(input: String, current: int, token: Token, ignore_dots := false) -> Token: + var next := 0 + var current_token := "" + # test for '--', '+-', '*-', '/-', '%-', or at least '-x' + var test_for_sign := (token == null or token.is_operator()) and input[current] == "-" + while current + next < len(input): + var character := input[current + next] as String + # if first charater a sign + # or allowend charset + # or is a float value + if (test_for_sign and next==0) \ + or is_allowed_character(character) \ + or (character == "." and (ignore_dots or current_token.is_valid_int())): + current_token += character + next += 1 + continue + break + if current_token != "": + return Variable.new(current_token) + return TOKEN_NOT_MATCH + + +# const ALLOWED_CHARACTERS := "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"" +func is_allowed_character(input: String) -> bool: + var code_point := input.unicode_at(0) + # Unicode + if code_point > 127: + # This is a Unicode character (Chinese, Japanese, etc.) + return true + # ASCII digit 0-9 + if code_point >= 48 and code_point <= 57: + return true + # ASCII lowercase a-z + if code_point >= 97 and code_point <= 122: + return true + # ASCII uppercase A-Z + if code_point >= 65 and code_point <= 90: + return true + # underscore _ + if code_point == 95: + return true + # quotes '" + if code_point == 34 or code_point == 39: + return true + return false + + +func parse_return_token(input: String) -> Variable: + var index := input.rfind(TOKEN_FUNCTION_RETURN_TYPE._token) + if index == -1: + return TOKEN_NOT_MATCH + index += TOKEN_FUNCTION_RETURN_TYPE._consumed + # We scan for the return value exclusive '.' token because it could be referenced to a + # external or internal class e.g. 'func foo() -> InnerClass.Bar:' + var token := next_token(input, index, [TOKEN_FUNCTION]) + while !token.is_variable() and token != TOKEN_NOT_MATCH: + index += token._consumed + token = next_token(input, index, [TOKEN_FUNCTION]) + return token + + +func get_function_descriptors(script: GDScript, included_functions: PackedStringArray = []) -> Array[GdFunctionDescriptor]: + var fds: Array[GdFunctionDescriptor] = [] + for method_descriptor in script.get_script_method_list(): + var func_name: String = method_descriptor["name"] + if included_functions.is_empty() or func_name in included_functions: + # exclude type set/geters + if is_getter_or_setter(func_name): + continue + if not fds.any(func(fd: GdFunctionDescriptor) -> bool: return fd.name() == func_name): + fds.append(GdFunctionDescriptor.extract_from(method_descriptor, false)) + + # we need to enrich it by default arguments and line number by parsing the script + # the engine core functions has no valid methods to get this info + _prescan_script(script) + _enrich_function_descriptor(script, fds) + return fds + + +func is_getter_or_setter(func_name: String) -> bool: + return func_name.begins_with("@") and (func_name.ends_with("getter") or func_name.ends_with("setter")) + + +func _parse_function_arguments(input: String) -> Array[Dictionary]: + var arguments: Array[Dictionary] = [] + var current_index := 0 + var token: Token = null + var bracket := 0 + var in_function := false + + + while current_index < len(input): + token = next_token(input, current_index) + # fallback to not end in a endless loop + if token == TOKEN_NOT_MATCH: + var error : = """ + Parsing Error: Invalid token at pos %d found. + Please report this error! + source_code: + -------------------------------------------------------------- + %s + -------------------------------------------------------------- + """.dedent() % [current_index, input] + push_error(error) + current_index += 1 + continue + current_index += token._consumed + if token.is_skippable(): + continue + if token == TOKEN_BRACKET_ROUND_OPEN : + in_function = true + bracket += 1 + if token == TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # if function end? + if in_function and bracket == 0: + return arguments + # is function + if token == TOKEN_FUNCTION_DECLARATION: + continue + + # is value argument + if in_function: + var arg_value := "" + var current_argument := { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : TYPE_VARIANT + } + + # parse type and default value + while current_index < len(input): + token = next_token(input, current_index) + current_index += token._consumed + if token.is_skippable(): + continue + + if token.is_variable() && current_argument["name"] == "": + arguments.append(current_argument) + current_argument["name"] = (token as Variable).plain_value() + continue + + match token: + # is fuzzer argument + TOKEN_ARGUMENT_FUZZER: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["name"] = (token as FuzzerToken).name() + current_argument["value"] = arg_value.lstrip(" ") + current_argument["type"] = TYPE_FUZZER + arguments.append(current_argument) + continue + + TOKEN_ARGUMENT_VARIADIC: + current_argument["type"] = TYPE_VARARG + + TOKEN_ARGUMENT_TYPE: + token = next_token(input, current_index) + if token == TOKEN_SPACE: + current_index += token._consumed + token = next_token(input, current_index) + current_index += token._consumed + if current_argument["type"] != TYPE_VARARG: + current_argument["type"] = GdObjects.string_to_type((token as Variable).plain_value()) + + TOKEN_ARGUMENT_TYPE_ASIGNMENT: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + TOKEN_ARGUMENT_ASIGNMENT: + token = next_token(input, current_index) + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + + TOKEN_BRACKET_SQUARE_OPEN: + bracket += 1 + TOKEN_BRACKET_CURLY_OPEN: + bracket += 1 + TOKEN_BRACKET_ROUND_OPEN : + bracket += 1 + # if value a function? + if bracket > 1: + # complete the argument value + var func_begin := input.substr(current_index-TOKEN_BRACKET_ROUND_OPEN ._consumed) + var func_body := _parse_end_function(func_begin) + arg_value += func_body + # fix parse index to end of value + current_index += func_body.length() - TOKEN_BRACKET_ROUND_OPEN ._consumed - TOKEN_BRACKET_ROUND_CLOSE._consumed + TOKEN_BRACKET_SQUARE_CLOSE: + bracket -= 1 + TOKEN_BRACKET_CURLY_CLOSE: + bracket -= 1 + TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # end of function + if bracket == 0: + break + TOKEN_ARGUMENT_SEPARATOR: + if bracket <= 1: + # next argument + current_argument = { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : GdObjects.TYPE_VARIANT + } + continue + return arguments + + +func _parse_end_function(input: String, remove_trailing_char := false) -> String: + # find end of function + var current_index := 0 + var bracket_count := 0 + var in_array := 0 + var in_dict := 0 + var end_of_func := false + + while current_index < len(input) and not end_of_func: + var character := input[current_index] + # step over strings + if character == "'" : + current_index = input.find("'", current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + if character == '"' : + # test for string blocks + if input.find('"""', current_index) == current_index: + current_index = input.find('"""', current_index+3) + 3 + else: + current_index = input.find('"', current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + + match character: + # count if inside an array + "[": in_array += 1 + "]": in_array -= 1 + # count if inside an dictionary + "{": in_dict += 1 + "}": in_dict -= 1 + # count if inside a function + "(": bracket_count += 1 + ")": + bracket_count -= 1 + if bracket_count < 0 and in_array <= 0 and in_dict <= 0: + end_of_func = true + ",": + if bracket_count == 0 and in_array == 0 and in_dict <= 0: + end_of_func = true + current_index += 1 + if remove_trailing_char: + # check if the parsed value ends with comma or end of doubled breaked + # `<value>,` or `<function>())` + var trailing_char := input[current_index-1] + if trailing_char == ',' or (bracket_count < 0 and trailing_char == ')'): + return input.substr(0, current_index-1) + return input.substr(0, current_index) + + +func extract_inner_class(source_rows: PackedStringArray, clazz_name :String) -> PackedStringArray: + for row_index in source_rows.size(): + var input := source_rows[row_index] + var token := next_token(input, 0) + if token.is_inner_class(): + @warning_ignore("unsafe_method_access") + if token.is_class_name(clazz_name): + @warning_ignore("unsafe_method_access") + token.parse(source_rows, row_index) + @warning_ignore("unsafe_method_access") + return token.content() + return PackedStringArray() + + +func extract_func_signature(rows: PackedStringArray, index: int) -> String: + var signature := "" + + for rowIndex in range(index, rows.size()): + var row := rows[rowIndex] + row = _regex_strip_comments.sub(row, "").strip_edges(false) + if row.is_empty(): + continue + signature += row + "\n" + if is_func_end(row): + return signature.strip_edges() + push_error("Can't fully extract function signature of '%s'" % rows[index]) + return "" + + +func get_class_name(script :GDScript) -> String: + var source_code := GdScriptParser.to_unix_format(script.source_code) + var source_rows := source_code.split("\n") + + for index :int in min(10, source_rows.size()): + var input := source_rows[index] + var token := next_token(input, 0) + if token == TOKEN_CLASS_NAME: + return token.value() + # if no class_name found extract from file name + return GdObjects.to_pascal_case(script.resource_path.get_basename().get_file()) + + +func parse_func_name(input: String) -> String: + if TOKEN_FUNCTION_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_DECLARATION.value() + if TOKEN_FUNCTION_STATIC_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_STATIC_DECLARATION.value() + push_error("Can't extract function name from '%s'" % input) + return "" + + +## Enriches the function descriptor by line number and argument default values +## - enrich all function descriptors form current script up to all inherited scrips +func _enrich_function_descriptor(script: GDScript, fds: Array[GdFunctionDescriptor]) -> void: + var enriched_functions := {} # Use Dictionary for O(1) lookup instead of PackedStringArray + var script_to_scan := script + while script_to_scan != null: + # do not scan the test suite base class itself + if script_to_scan.resource_path == "res://addons/gdUnit4/src/GdUnitTestSuite.gd": + break + + var rows := script_to_scan.source_code.split("\n") + for rowIndex in rows.size(): + var input := rows[rowIndex] + # step over inner class functions + if input.begins_with("\t"): + continue + # skip comments and empty lines + if input.begins_with("#") or input.length() == 0: + continue + var token := next_token(input, 0) + if token != TOKEN_FUNCTION_STATIC_DECLARATION and token != TOKEN_FUNCTION_DECLARATION: + continue + + var function_name: String = token.value() + # Skip if already enriched (from parent class scan) + if enriched_functions.has(function_name): + continue + + # Find matching function descriptor + var fd: GdFunctionDescriptor = null + for candidate in fds: + if candidate.name() == function_name: + fd = candidate + break + if fd == null: + continue + # Mark as enriched + enriched_functions[function_name] = true + var func_signature := extract_func_signature(rows, rowIndex) + var func_arguments := _parse_function_arguments(func_signature) + # enrich missing default values + fd.enrich_arguments(func_arguments) + fd.enrich_file_info(script_to_scan.resource_path, rowIndex + 1) + fd._is_coroutine = is_func_coroutine(rows, rowIndex) + # enrich return class name if not set + if fd.return_type() == TYPE_OBJECT and fd._return_class in ["", "Resource", "RefCounted"]: + var var_token := parse_return_token(func_signature) + if var_token != TOKEN_NOT_MATCH and var_token.type() == TYPE_OBJECT: + fd._return_class = _patch_inner_class_names(var_token.plain_value(), "") + # if the script ihnerits we need to scan this also + script_to_scan = script_to_scan.get_base_script() + + +func is_func_coroutine(rows :PackedStringArray, index :int) -> bool: + var is_coroutine := false + for rowIndex in range(index+1, rows.size()): + var input := rows[rowIndex].strip_edges() + if input.begins_with("#") or input.is_empty(): + continue + var token := next_token(input, 0) + # scan until next function + if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION: + break + + if _is_awaiting.search(input): + return true + return is_coroutine + + +func is_inner_class(clazz_path :PackedStringArray) -> bool: + return clazz_path.size() > 1 + + +func is_func_end(row :String) -> bool: + return row.strip_edges(false, true).ends_with(":") + + +func _patch_inner_class_names(clazz :String, clazz_name :String = "") -> String: + var inner_clazz_name := clazz.split(".")[0] + if _scanned_inner_classes.has(inner_clazz_name): + return inner_clazz_name + #var base_clazz := clazz_name.split(".")[0] + #return base_clazz + "." + clazz + if _script_constants.has(clazz): + return clazz_name + "." + clazz + return clazz + + +func _prescan_script(script: GDScript) -> void: + _script_constants = script.get_script_constant_map() + for key :String in _script_constants.keys(): + var value :Variant = _script_constants.get(key) + if value is GDScript: + @warning_ignore("return_value_discarded") + _scanned_inner_classes.append(key) + + +func parse(clazz_name :String, clazz_path :PackedStringArray) -> GdUnitResult: + if clazz_path.is_empty(): + return GdUnitResult.error("Invalid script path '%s'" % clazz_path) + var is_inner_class_ := is_inner_class(clazz_path) + var script :GDScript = load(clazz_path[0]) + _prescan_script(script) + + if is_inner_class_: + var inner_class_name := clazz_path[1] + if _scanned_inner_classes.has(inner_class_name): + # do load only on inner class source code and enrich the stored script instance + var source_code := _load_inner_class(script, inner_class_name) + script = _script_constants.get(inner_class_name) + script.source_code = source_code + var function_descriptors := get_function_descriptors(script) + var gd_class := GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors) + return GdUnitResult.success(gd_class) + + +func _load_inner_class(script: GDScript, inner_clazz: String) -> String: + var source_rows := GdScriptParser.to_unix_format(script.source_code).split("\n") + # extract all inner class names + var inner_class_code := extract_inner_class(source_rows, inner_clazz) + return "\n".join(inner_class_code) diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid new file mode 100644 index 0000000..11d95e1 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid @@ -0,0 +1 @@ +uid://djbak301ao2c0 diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd new file mode 100644 index 0000000..9faf830 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd @@ -0,0 +1,74 @@ +class_name GdUnitExpressionRunner +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ExpressionRunner extends '${clazz_path}' + +func __run_expression() -> Variant: + return $expression + +""" + +var constructor_args_regex := RegEx.create_from_string("new\\((?<args>.*)\\)") + + +func execute(src_script: GDScript, value: Variant) -> Variant: + if typeof(value) != TYPE_STRING: + return value + + var expression: String = value + var parameter_map := src_script.get_script_constant_map() + for key: String in parameter_map.keys(): + var parameter_value: Variant = parameter_map[key] + # check we need to construct from inner class + # we need to use the original class instance from the script_constant_map otherwise we run into a runtime error + if expression.begins_with(key + ".new") and parameter_value is GDScript: + var object: GDScript = parameter_value + var args := build_constructor_arguments(parameter_map, expression.substr(expression.find("new"))) + if args.is_empty(): + return object.new() + return object.callv("new", args) + + var script := GDScript.new() + var resource_path := "res://addons/gdUnit4/src/Fuzzers.gd" if src_script.resource_path.is_empty() else src_script.resource_path + script.source_code = CLASS_TEMPLATE.dedent()\ + .replace("${clazz_path}", resource_path)\ + .replace("$expression", expression) + #script.take_over_path(resource_path) + @warning_ignore("return_value_discarded") + script.reload(true) + var runner: Object = script.new() + if runner.has_method("queue_free"): + (runner as Node).queue_free() + @warning_ignore("unsafe_method_access") + return runner.__run_expression() + + +func build_constructor_arguments(parameter_map: Dictionary, expression: String) -> Array[Variant]: + var result := constructor_args_regex.search(expression) + var extracted_arguments := result.get_string("args").strip_edges() + if extracted_arguments.is_empty(): + return [] + var arguments :Array = extracted_arguments.split(",") + return arguments.map(func(argument: String) -> Variant: + var value := argument.strip_edges() + + # is argument an constant value + if parameter_map.has(value): + return parameter_map[value] + # is typed named value like Vector3.ONE + for type:int in GdObjects.TYPE_AS_STRING_MAPPINGS: + var type_as_string:String = GdObjects.TYPE_AS_STRING_MAPPINGS[type] + if value.begins_with(type_as_string): + return type_convert(value, type) + # is value a string + if value.begins_with("'") or value.begins_with('"'): + return value.trim_prefix("'").trim_suffix("'").trim_prefix('"').trim_suffix('"') + # fallback to default value converting + return str_to_var(value) + ) + + +func to_fuzzer(src_script: GDScript, expression: String) -> Fuzzer: + @warning_ignore("unsafe_cast") + return execute(src_script, expression) as Fuzzer diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid new file mode 100644 index 0000000..54da916 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid @@ -0,0 +1 @@ +uid://cstpl6o675qkp diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd new file mode 100644 index 0000000..527661d --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd @@ -0,0 +1,163 @@ +## @deprecated see GdFunctionParameterSetResolver +class_name GdUnitTestParameterSetResolver +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ParameterExtractor extends '${clazz_path}' + +func __extract_test_parameters() -> Array: + return ${test_params} + +""" + +const EXCLUDE_PROPERTIES_TO_COPY = [ + "script", + "type", + "Node", + "_import_path"] + + +var _fd: GdFunctionDescriptor +var _static_sets_by_index := {} +var _is_static := true + +func _init(fd: GdFunctionDescriptor) -> void: + _fd = fd + + +func is_parameterized() -> bool: + return _fd.is_parameterized() + + +func is_parameter_sets_static() -> bool: + return _is_static + + +func is_parameter_set_static(index: int) -> bool: + return _is_static and _static_sets_by_index.get(index, false) + + +# validates the given arguments are complete and matches to required input fields of the test function +func validate(parameter_sets: Array, parameter_set_index: int) -> GdUnitResult: + if parameter_sets.size() < parameter_set_index: + return GdUnitResult.error("Internal error: the resolved paremeterset has invalid size.") + + var input_values: Array = parameter_sets[parameter_set_index] + if input_values == null: + return GdUnitResult.error("The parameter set '%s' must be an Array!" % parameter_sets[parameter_set_index]) + + # check given parameter set with test case arguments + var input_arguments := _fd.args() + var expected_arg_count := input_arguments.size() - 1 #(-1 we exclude the parameter set itself) + var current_arg_count := input_values.size() + if current_arg_count != expected_arg_count: + var arg_names := input_arguments\ + .filter(func(arg: GdFunctionArgument) -> bool: return not arg.is_parameter_set())\ + .map(func(arg: GdFunctionArgument) -> String: return str(arg)) + + return GdUnitResult.error(""" + The test data set at index (%d) does not match the expected test arguments: + test function: [color=snow]func test...(%s)[/color] + test input values: [color=snow]%s[/color] + """ + .dedent() % [parameter_set_index, ",".join(arg_names), input_values]) + return GdUnitTestParameterSetResolver.validate_parameter_types(input_arguments, input_values) + + +static func validate_parameter_types(input_arguments: Array[GdFunctionArgument], input_values: Array) -> GdUnitResult: + for i in input_arguments.size(): + var input_param: GdFunctionArgument = input_arguments[i] + # only check the test input arguments + if input_param.is_parameter_set(): + continue + var input_param_type := input_param.type() + var input_value :Variant = input_values[i] + var input_value_type := typeof(input_value) + # input parameter is not typed or is Variant we skip the type test + if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT: + continue + # is input type enum allow int values + if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT: + continue + # allow only equal types and object == null + if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL: + continue + if input_param_type != input_value_type: + return GdUnitResult.error(""" + The test data value does not match the expected input type! + input value: [color=snow]'%s', <%s>[/color] + expected argument: [color=snow]%s[/color] + """ + .dedent() % [input_value, type_string(input_value_type), str(input_param)]) + return GdUnitResult.success("No errors found.") + + +func _extract_property_names(node :Node) -> PackedStringArray: + return node.get_property_list()\ + .map(func(property :Dictionary) -> String: return property["name"])\ + .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property)) + + +# tests if the test property set contains an property reference by name, if not the parameter set holds only static values +func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool: + for property_name in property_names: + if parameters.contains(property_name): + _is_static = false + return false + return true + + +# extracts the arguments from the given test case, using kind of reflection solution +# to restore the parameters from a string representation to real instance type +func load_parameter_sets(test_suite: Node) -> GdUnitResult: + var source_script: Script = test_suite.get_script() + var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args()) + var source_code := CLASS_TEMPLATE \ + .replace("${clazz_path}", source_script.resource_path) \ + .replace("${test_params}", parameter_arg.value_as_string()) + var script := GDScript.new() + script.source_code = source_code + # enable this lines only for debuging + #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name() + #DirAccess.remove_absolute(script.resource_path) + #ResourceSaver.save(script, script.resource_path) + var result := script.reload() + if result != OK: + return GdUnitResult.error("Extracting test parameters failed! Script loading error: %s" % error_string(result)) + var instance :Object = script.new() + GdUnitTestParameterSetResolver.copy_properties(test_suite, instance) + (instance as Node).queue_free() + var parameter_sets: Array = instance.call("__extract_test_parameters") + fixure_typed_parameters(parameter_sets, _fd.args()) + return GdUnitResult.success(parameter_sets) + + +func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array: + for parameter_set_index in parameter_sets.size(): + var parameter_set: Array = parameter_sets[parameter_set_index] + # run over all function arguments + for parameter_index in parameter_set.size(): + var parameter :Variant = parameter_set[parameter_index] + var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index] + if parameter is Array: + var as_array: Array = parameter + # we need to convert the untyped array to the expected typed version + if arg_descriptor.is_typed_array(): + parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null) + return parameter_sets + + +static func copy_properties(source: Object, dest: Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, "<empty>"); + continue + dest.set(property_name, property_value) diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid new file mode 100644 index 0000000..a12fb18 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://deasps2102or7 |
