diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd b/addons/gdUnit4/src/GdUnitSceneRunner.gd index 9b156f3ac..85e54587e 100644 --- a/addons/gdUnit4/src/GdUnitSceneRunner.gd +++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd @@ -278,7 +278,6 @@ extends RefCounted ## [/codeblock] @abstract func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert - ## The await_func_on function extends the functionality of await_func by allowing you to specify a source node within the scene.[br] ## It waits for a specified function on that node to return a value and returns a [GdUnitFuncAssert] object for assertions.[br] ## [member source] : The object where implements the function.[br] @@ -319,6 +318,16 @@ extends RefCounted @abstract func move_window_to_background() -> GdUnitSceneRunner +## Loads existing reference screenshot or saves current screenshot as new reference +## [param path]: Relative path from test file location (e.g., "screenshots/button_normal.png") +## [param source_node]: Optional node to capture (default: full scene viewport) +@warning_ignore("unused_parameter") +func capture_initial_screen_shot(source_node: Node, path: String) -> Image: + @warning_ignore("assert_always_true") + assert(true, "'initial_screen_shot' is not implemented!") + return null + + ## Return the current value of the property with the name .[br] ## [member name] : name of property[br] ## [member return] : the value of the property diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd b/addons/gdUnit4/src/GdUnitTestSuite.gd index 5cf269f59..fe9659d85 100644 --- a/addons/gdUnit4/src/GdUnitTestSuite.gd +++ b/addons/gdUnit4/src/GdUnitTestSuite.gd @@ -313,9 +313,9 @@ func reset(obj: Variant) -> void: ## [/codeblock] func monitor_signals(source: Object, _auto_free := true) -> Object: @warning_ignore("unsafe_method_access") - __lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd")\ - .get_current_context()\ - .get_signal_collector()\ + __lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd") \ + .get_current_context() \ + .get_signal_collector() \ .register_emitter(source, true) # force recreate to start with a fresh monitoring return auto_free(source) if _auto_free else source @@ -533,7 +533,7 @@ func any_packed_color_array() -> GdUnitArgumentMatcher: ## Argument matcher to match any instance of given class -func any_class(clazz :Object) -> GdUnitArgumentMatcher: +func any_class(clazz: Object) -> GdUnitArgumentMatcher: @warning_ignore("unsafe_method_access") return __gdunit_argument_matchers().any_class(clazz) @@ -593,8 +593,8 @@ func assert_that(current: Variant) -> GdUnitAssert: return assert_vector(current, false) TYPE_DICTIONARY: return assert_dict(current) - TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,\ - TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,\ + TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \ + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, \ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY: return assert_array(current, false) TYPE_OBJECT, TYPE_NIL: @@ -653,6 +653,11 @@ func assert_object(current: Variant) -> GdUnitObjectAssert: return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd").new(current) +## An assertion for UI and visual testing of Godot nodes +func assert_ui(current: Node) -> GdUnitUIAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitUIAssertImpl.gd").new(current) + + func assert_result(current: Variant) -> GdUnitResultAssert: return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd").new(current) diff --git a/addons/gdUnit4/src/GdUnitUIAssert.gd b/addons/gdUnit4/src/GdUnitUIAssert.gd new file mode 100644 index 000000000..48bc0f956 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitUIAssert.gd @@ -0,0 +1,331 @@ +## GdUnitUIAssert provides assertions for UI and visual testing of Godot nodes.[br] +## [br] +## This class extends GdUnitAssert to offer specialized testing capabilities for user interfaces,[br] +## including screenshot comparison, layout validation, positioning checks, and visibility testing.[br] +## [br] +## [color=yellow][i]Use this class through the assert_ui() function for fluent assertion syntax[/i][/color][br] +## [br] +## Common usage patterns:[br] +## - Screenshot regression testing with configurable tolerances[br] +## - Layout validation for size and positioning[br] +## - Visibility state verification[br] +## - Cross-platform UI consistency testing[br] +## [br] +## [b]Example:[/b] Basic screenshot testing[br] +## [codeblock] +## func test_button_appearance(): +## var button = scene.get_node("Button") +## var expected_image = load("res://test/screenshots/button_normal.png") +## assert_ui(button).is_equal_screenshot(expected_image, GdUnitUIConfig.ui_testing()) +## [/codeblock] +## [br] +## [b]Example:[/b] Layout validation[br] +## [codeblock] +## func test_dialog_layout(): +## var dialog = scene.get_node("Dialog") +## assert_ui(dialog).is_equal_size(Vector2i(400, 300)) +## assert_ui(dialog).is_visible() +## [/codeblock] +@abstract class_name GdUnitUIAssert +extends GdUnitAssert + + +## Configuration class for UI assertion parameters.[br] +## [br] +## GdUnitUIConfig provides a fluent API for configuring visual comparison tolerances,[br] +## cropping regions, and other parameters used in UI testing assertions.[br] +## [br] +## [color=yellow][i]Use the static factory methods for common configurations or build custom ones with the fluent API[/i][/color][br] +## [br] +## Configuration options:[br] +## - Pixel-level tolerance for color differences[br] +## - Overall image difference thresholds[br] +## - Alpha channel handling[br] +## - Region cropping for partial comparisons[br] +## - Diff image generation control[br] +## [br] +## [b]Example:[/b] Using preset configurations[br] +## [codeblock] +## # Exact match (default) +## var strict_config = GdUnitUIConfig.strict() +## +## # Allow small differences for anti-aliasing +## var aa_config = GdUnitUIConfig.anti_aliasing() +## [/codeblock] +## [br] +## [b]Example:[/b] Building custom configuration[br] +## [codeblock] +## var custom_config = GdUnitUIConfig.of() +## .pixel_tolerance(0.02) +## .difference_threshold(0.05) +## .ignore_alpha(true) +## [/codeblock] +class GdUnitUIConfig extends RefCounted: + var _config_name: String + var _pixel_tolerance: float = 0.0 ## Tolerance for individual pixel differences (0.0 = exact match, 1.0 = any difference allowed) + var _difference_threshold: float = 0.0 ## Threshold for overall image differences (0.0 = no different pixels, 1.0 = all pixels can differ) + var _ignore_alpha: bool = false ## Whether to ignore alpha channel differences + var _save_diff_image: bool = true ## Whether to save difference images on test failure + + func _init(name :String) -> void: + _config_name = name + + + ## Creates a new GdUnitUIConfig instance with default values.[br] + ## [br] + ## [color=yellow][i]This is the base factory method for building custom configurations[/i][/color][br] + ## [br] + ## Default values:[br] + ## - pixel_tolerance: 0.0 (exact pixel match required)[br] + ## - difference_threshold: 0.0 (no different pixels allowed)[br] + ## - ignore_alpha: false (alpha channel differences matter)[br] + ## - crop_region: empty (compare full images)[br] + ## - save_diff_image: true (generate diff images on failure)[br] + ## [br] + ## [b]return:[/b] A new GdUnitUIConfig instance with default strict settings + static func of(name :String = "custom") -> GdUnitUIConfig: + return GdUnitUIConfig.new(name) + + + ## Sets the tolerance for individual pixel differences.[br] + ## [br] + ## Controls how much individual pixels can differ before being considered "different".[br] + ## Uses euclidean distance between RGBA color values for comparison.[br] + ## [br] + ## [color=yellow][i]Higher values allow more color variation per pixel[/i][/color][br] + ## [br] + ## Tolerance guidelines:[br] + ## - 0.0: Exact color match required[br] + ## - 0.01-0.02: Very strict, good for pixel-perfect UI[br] + ## - 0.03-0.05: Moderate, handles anti-aliasing differences[br] + ## - 0.1+: Lenient, allows significant color variations[br] + ## [br] + ## [param tolerance] Tolerance value between 0.0 (exact match) and 1.0 (any difference allowed)[br] + ## [b]return:[/b] This config instance for method chaining + func pixel_tolerance(tolerance: float) -> GdUnitUIConfig: + _pixel_tolerance = clamp(tolerance, 0.0, 1.0) + return self + + + ## Sets the threshold for overall image differences.[br] + ## [br] + ## Controls what percentage of pixels can be "different" before the comparison fails.[br] + ## Works in combination with pixel_tolerance to determine overall image matching.[br] + ## [br] + ## [color=yellow][i]This is the final gate - even if pixels pass pixel_tolerance, too many different pixels will fail the test[/i][/color][br] + ## [br] + ## Threshold guidelines:[br] + ## - 0.0: No different pixels allowed[br] + ## - 0.01-0.05: Very strict overall matching[br] + ## - 0.05-0.1: Moderate, good for UI components[br] + ## - 0.1+: Lenient, allows significant image differences[br] + ## [br] + ## [param threshold] Threshold value between 0.0 (no different pixels) and 1.0 (all pixels can differ)[br] + ## [b]return:[/b] This config instance for method chaining + func difference_threshold(threshold: float) -> GdUnitUIConfig: + _difference_threshold = clamp(threshold, 0.0, 1.0) + return self + + + ## Configures whether to ignore alpha channel differences.[br] + ## [br] + ## When enabled, only RGB values are compared, ignoring transparency differences.[br] + ## Useful when testing elements that may have slight transparency variations.[br] + ## [br] + ## [color=yellow][i]Enable this when alpha differences are not important for your test case[/i][/color][br] + ## [br] + ## Use cases:[br] + ## - Testing UI elements with animated transparency[br] + ## - Comparing images with different alpha channels[br] + ## - Focusing on color content rather than transparency[br] + ## - Cross-platform testing where alpha rendering varies[br] + ## [br] + ## [b]return:[/b] This config instance for method chaining + func ignore_alpha() -> GdUnitUIConfig: + _ignore_alpha = true + return self + + + ## Configures whether to save difference images on test failure.[br] + ## [br] + ## When enabled, creates a visual diff image highlighting differences between[br] + ## expected and actual screenshots, useful for debugging test failures.[br] + ## [br] + ## [color=yellow][i]Disable this in CI environments or when running many visual tests to save disk space[/i][/color][br] + ## [br] + ## Benefits when enabled:[br] + ## - Visual debugging of test failures[br] + ## - Quick identification of rendering differences[br] + ## - Historical record of visual changes[br] + ## - Easier test maintenance and updates[br] + ## [br] + ## [param save] Whether to save difference images on test failure[br] + ## [b]return:[/b] This config instance for method chaining + func save_diff_image(save: bool = true) -> GdUnitUIConfig: + _save_diff_image = save + return self + + + ## Creates a strict configuration requiring exact pixel-perfect matching.[br] + ## [br] + ## Equivalent to pixel_tolerance(0.0) and difference_threshold(0.0).[br] + ## Use when you need exact visual matches without any tolerance.[br] + ## [br] + ## [color=yellow][i]Best for pixel-perfect UI testing and reference image validation[/i][/color][br] + ## [br] + ## Recommended for:[br] + ## - Icon and sprite testing[br] + ## - Pixel-perfect UI layouts[br] + ## - Reference image validation[br] + ## - High-precision visual regression testing[br] + ## [br] + ## [b]return:[/b] A strict GdUnitUIConfig instance with zero tolerance + static func strict() -> GdUnitUIConfig: + return GdUnitUIConfig.of("strict") # 0.0/0.0 = exact match + + + ## Creates a lenient configuration allowing significant visual differences.[br] + ## [br] + ## Allows 5% pixel color differences and up to 10% of pixels to differ.[br] + ## Useful for testing dynamic content or animations where exact matching isn't feasible.[br] + ## [br] + ## [color=yellow][i]Use for testing dynamic content, animations, or when minor visual differences are acceptable[/i][/color][br] + ## [br] + ## Recommended for:[br] + ## - Animated UI elements[br] + ## - Dynamic content that changes between runs[br] + ## - Cross-platform testing with rendering variations[br] + ## - Testing where perfect matching is not critical[br] + ## [br] + ## [b]return:[/b] A lenient GdUnitUIConfig instance (5% pixel tolerance, 10% difference threshold) + static func lenient() -> GdUnitUIConfig: + return GdUnitUIConfig.of("lenient").pixel_tolerance(0.05).difference_threshold(0.1) + + + ## Creates a configuration optimized for UI component testing.[br] + ## [br] + ## Allows 2% pixel color differences and up to 5% of pixels to differ.[br] + ## Good balance for testing UI elements that may have minor rendering variations.[br] + ## [br] + ## [color=yellow][i]This is the recommended starting point for most UI component testing[/i][/color][br] + ## [br] + ## Recommended for:[br] + ## - Button and control testing[br] + ## - Dialog and panel validation[br] + ## - Form and layout testing[br] + ## - General UI component regression testing[br] + ## [br] + ## [b]return:[/b] A UI testing optimized GdUnitUIConfig instance (2% pixel tolerance, 5% difference threshold) + static func ui_testing() -> GdUnitUIConfig: + return GdUnitUIConfig.of("ui_testing").pixel_tolerance(0.02).difference_threshold(0.05) + + + ## Creates a configuration that handles anti-aliasing differences.[br] + ## [br] + ## Allows 3% pixel color differences but only 2% of pixels can differ overall.[br] + ## Designed for content with anti-aliased edges that may render slightly differently.[br] + ## [br] + ## [color=yellow][i]Use when testing content with smooth edges, fonts, or anti-aliased graphics[/i][/color][br] + ## [br] + ## Recommended for:[br] + ## - Text and font rendering[br] + ## - Smooth graphics and curves[br] + ## - Anti-aliased UI elements[br] + ## - Vector graphics and scalable content[br] + ## [br] + ## [b]return:[/b] An anti-aliasing tolerant GdUnitUIConfig instance (3% pixel tolerance, 2% difference threshold) + static func anti_aliasing() -> GdUnitUIConfig: + return GdUnitUIConfig.of("anti_aliasing").pixel_tolerance(0.03).difference_threshold(0.02) + + +## Asserts that the node's screenshot matches the expected image.[br] +## [br] +## Takes a screenshot of the source node and compares it pixel-by-pixel with the expected image.[br] +## The comparison behavior is controlled by the provided configuration.[br] +## [br] +## [color=yellow][i]This is the primary method for visual regression testing[/i][/color][br] +## [br] +## Comparison process:[br] +## - Captures current state of the node as an image[br] +## - Applies any configured cropping or preprocessing[br] +## - Compares each pixel using the specified tolerances[br] +## - Generates diff images if enabled and test fails[br] +## [br] +## [param expected] The expected image to compare against[br] +## [param config] Configuration for the comparison (default is strict mode)[br] +## [b]return:[/b] This assert instance for method chaining[br] +## [br] +## [b]Example:[/b] Basic screenshot comparison[br] +## [codeblock] +## var expected = load("res://test/screenshots/button.png") +## assert_ui(button).is_equal_screenshot(expected, GdUnitUIConfig.ui_testing()) +## [/codeblock] +@abstract func is_equal_screenshot(expected: Image, config: GdUnitUIConfig = GdUnitUIConfig.strict()) -> GdUnitUIAssert + + +## Asserts that the 2D node is at the expected position.[br] +## [br] +## Checks the global_position of 2D nodes (Control, Node2D and their subclasses).[br] +## For 3D nodes, use is_equal_position3D() instead.[br] +## [br] +## [color=yellow][i]Uses global_position for accurate screen positioning regardless of parent transforms[/i][/color][br] +## [br] +## Supported node types:[br] +## - Control: Uses global_position[br] +## - Node2D: Uses global_position[br] +## - CanvasItem subclasses: Uses global_position[br] +## - Automatically handles parent transformations[br] +## [br] +## [param expected_position] The expected 2D position in global coordinates[br] +## [b]return:[/b] This assert instance for method chaining[br] +## [br] +## [b]Example:[/b] Button position validation[br] +## [codeblock] +## assert_ui(button).is_equal_position2D(Vector2(100, 50)) +## [/codeblock] +@abstract func is_equal_position2D(expected_position: Vector2) -> GdUnitUIAssert + + +## Asserts that the 3D node is at the expected position.[br] +## [br] +## Checks the global_position of 3D nodes (Node3D and subclasses).[br] +## For 2D nodes, use is_equal_position2D() instead.[br] +## [br] +## [color=yellow][i]Uses global_position for accurate world positioning regardless of parent transforms[/i][/color][br] +## [br] +## Supported node types:[br] +## - Node3D: Uses global_position[br] +## - MeshInstance3D: Uses global_position[br] +## - CharacterBody3D: Uses global_position[br] +## - All Node3D subclasses: Uses global_position[br] +## [br] +## [param expected_position] The expected 3D position in global coordinates[br] +## [b]return:[/b] This assert instance for method chaining[br] +## [br] +## [b]Example:[/b] Player model position validation[br] +## [codeblock] +## assert_ui(player_model).is_equal_position3D(Vector3(0, 5, 10)) +## [/codeblock] +@abstract func is_equal_position3D(expected_position: Vector3) -> GdUnitUIAssert + + +## Asserts that the node is visible.[br] +## [br] +## Checks visibility depending on node type using appropriate visibility methods.[br] +## Considers both the node's visibility state and its visibility in the scene tree.[br] +## [br] +## [color=yellow][i]Visibility checking varies by node type and considers the full scene tree hierarchy[/i][/color][br] +## [br] +## Visibility checks by node type:[br] +## - Control nodes: Uses is_visible_in_tree()[br] +## - CanvasItem nodes: Uses is_visible_in_tree()[br] +## - Node3D nodes: Uses is_visible_in_tree() and checks visibility flags[br] +## - Considers parent visibility and scene tree state[br] +## [br] +## [b]return:[/b] This assert instance for method chaining[br] +## [br] +## [b]Example:[/b] Popup dialog visibility[br] +## [codeblock] +## assert_ui(popup_dialog).is_visible() +## [/codeblock] +@abstract func is_visible() -> GdUnitUIAssert diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd b/addons/gdUnit4/src/asserts/GdAssertMessages.gd index ff7ef4c46..f0a368198 100644 --- a/addons/gdUnit4/src/asserts/GdAssertMessages.gd +++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd @@ -278,6 +278,48 @@ static func error_is_not_null() -> String: return "%s %s" % [_error("Expecting: not to be"), _colored_value(null)] +static func error_is_equal_screen_shot(error_info: Dictionary) -> String: + var metrics := """ + [color=#1E90FF][font_size=16][b]Metrics:[/b][/font_size] + [b]Component:[/b] <{node_name}> + [b]Node Path:[/b] {node_path} + [b]Resolution:[/b] {resolution} pixels + [b]Config:[/b] {config} + - pixel tolerance: {config.pixel_tolerance}% + - difference threshold: {config.difference_threshold}% + - ignore alpha: {config.is_ignore_alpha} + - {different_pixels} pixels of total {total_pixels} differs ({different_pixels_percentage}% of total) + - Avg color distance: {average_color_distance} (threshold: {difference_threshold}) + [/color]""".format(error_info) + + return """{0} + {1} + [color=#1E90FF][font_size=16][b]Comparision:[/b][/font_size][/color] + [table=3] + [cell border=gray][center]Expected[/center][/cell][cell border=gray][center]Actual[/center][/cell][cell border=gray][center]Difference[/center][/cell] + [cell border=gray][img]{2}[/img][/cell][cell border=gray][img]{3}[/img][/cell][cell border=gray][img]{4}[/img][/cell] + [/table] + """.format([ + _error("Visual regression detected in '{0}':".format([error_info["node_name"]])), + metrics, + error_info["reference_image_path"], + error_info["captured_image_path"], + error_info["difference_image_path"], + ]) + + +static func error_is_equal_screen_shot_diff_size(error_info: Dictionary) -> String: + return """ + {0} + {1} + but was + {2}""".format([ + _error("Expecting same component size:"), + error_info["actual_size"], + error_info["reference_size"] + ]) + + static func error_equal(current :Variant, expected :Variant, index_reports :Array = []) -> String: var report := """ %s diff --git a/addons/gdUnit4/src/asserts/GdUnitUIAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitUIAssertImpl.gd new file mode 100644 index 000000000..e6bf8b74f --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitUIAssertImpl.gd @@ -0,0 +1,243 @@ +extends GdUnitUIAssert + +var _base: GdUnitAssertImpl + +const CURRENT_SCREENSHOTS_PATH = "{0}/visual_regression/{2}-{1}/{2}_current.png" +const DIFF_SCREENSHOTS_PATH = "{0}/visual_regression/{2}-{1}/{2}_diff.png" +const EXPECTED_SCREENSHOTS_PATH = "{0}/visual_regression/{2}-{1}/{2}_expected.png" + +func _init(current: Node) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + + +func _notification(event: int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func failure_message() -> String: + return _base.failure_message() + + +func current_value() -> Node: + return _base.current_value() + + +func report_success() -> GdUnitUIAssert: + _base.report_success() + return self + + +func report_error(error: String) -> GdUnitUIAssert: + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitUIAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitUIAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitUIAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitUIAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitUIAssert: + var current := current_value() + if current == null: + return report_error(GdAssertMessages.error_equal(current, expected)) + + if not expected is Node: + return report_error("Unexpected type <%s> used for argument 'expected'." % GdObjects.typeof_as_string(expected)) + + var expected_node: Node = expected + + if expected_node.get_path() != current.get_path(): + return report_error(GdAssertMessages.error_equal(current.get_path(), expected_node.get_path())) + return report_success() + + +func is_not_equal(expected: Variant) -> GdUnitUIAssert: + assert(false, "is_not_equal() is not yet implemented!") + return self + + +func is_equal_screenshot(reference_image: Image, config: GdUnitUIConfig = GdUnitUIConfig.strict()) -> GdUnitUIAssert: + if DisplayServer.get_name() == "headless": + return report_error("is_equal_screenshot() is not supported on 'headless' mode!") + + if reference_image == null: + return report_error("Expected argument 'reference_image' is null.") + + var current := current_value() + if current == null: + return report_error("The current node is null, not able to take a screenshot.") + + var captured_image := GdUnitUiTools.capture_image(current) + if captured_image == null: + return report_error("Can't capture image from node {0}".format([current.get_path()])) + + var result := _compare_images(current, reference_image, captured_image, config) + if result.has("error_not_match"): + return report_error(GdAssertMessages.error_is_equal_screen_shot(result)) + elif result.has("error_diff_size"): + return report_error(GdAssertMessages.error_is_equal_screen_shot_diff_size(result)) + + return report_success() + + + +func is_equal_position2D(expected_position: Vector2) -> GdUnitUIAssert: + assert(false, "is_equal_position2D() is not yet implemented!") + return self + + +func is_equal_position3D(expected_position: Vector3) -> GdUnitUIAssert: + assert(false, "is_equal_position3D() is not yet implemented!") + return self + + +func is_visible() -> GdUnitUIAssert: + assert(false, "is_visible() is not yet implemented!") + return self + + +func _compare_images(node: Node, reference_image: Image, actual_image: Image, config: GdUnitUIConfig) -> Dictionary: + var node_name := node.name.replace("@", "") + + if reference_image.get_size() != actual_image.get_size(): + return { + "error_diff_size": true, + "node_name" : node_name, + "actual_size": actual_image.get_size(), + "reference_size": reference_image.get_size(), + } + + var width := reference_image.get_width() + var height := reference_image.get_height() + var total_pixels := width * height + var different_pixels: float = 0.0 + # Track difference positions for cluster analysis + var difference_positions: Array[Vector2i] = [] + var pixel_differences: Array[float] = [] + + # Create diff image for debugging + var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8) + var no_diff_color := Color(0, 0, 0, 0.1) + + for y in range(height): + for x in range(width): + var ref_pixel := reference_image.get_pixel(x, y) + var actual_pixel := actual_image.get_pixel(x, y) + var pixel_diff := _calculate_pixel_difference(ref_pixel, actual_pixel, config._ignore_alpha) + + if pixel_diff > config._pixel_tolerance: + different_pixels += 1 + pixel_differences.append(pixel_diff) + diff_image.set_pixel(x, y, actual_pixel) + difference_positions.append(Vector2i(x, y)) + else: + # Keep original pixel + diff_image.set_pixel(x, y, no_diff_color) + + var difference_ratio := different_pixels / float(total_pixels) + var matches := difference_ratio <= config._difference_threshold + + # Save diff image if there are differences + if not matches: + # Build image paths for reporting based on session report path + #var test_session: GdUnitTestSession = Engine.get_meta("GdUnitTestSession") + var report_path :String = "res://reports" + report_path = "{0}/{1}_{2}".format([report_path, randi(), GdUnitThreadManager.get_current_context().get_execution_context().get_test_case_name()]) + var difference_image_path := DIFF_SCREENSHOTS_PATH.format([report_path, node.get_instance_id(), node_name]) + var captured_image_path := CURRENT_SCREENSHOTS_PATH.format([report_path, node.get_instance_id(), node_name]) + var reference_image_path := EXPECTED_SCREENSHOTS_PATH.format([report_path, node.get_instance_id(), node_name]) + DirAccess.make_dir_recursive_absolute(difference_image_path.get_base_dir()) + diff_image.save_png(difference_image_path) + reference_image.save_png(reference_image_path) + actual_image.save_png(captured_image_path) + + var color_analysis := _analyze_color_differences(pixel_differences) + + return { + "error_not_match" : true, + "config": "{0}".format([config._config_name]), + "config.pixel_tolerance" : config._pixel_tolerance, + "config.difference_threshold" : config._difference_threshold, + "config.is_ignore_alpha" : config._ignore_alpha, + "node_name" : node_name, + "node_path" : node.get_path(), + "resolution": reference_image.get_size(), + "total_pixels": total_pixels, + "difference_ratio": difference_ratio, + "difference_threshold": config._difference_threshold, + "different_pixels": different_pixels, + "different_pixels_percentage": str((100.0/total_pixels) * different_pixels).substr(0, 4), + "average_color_distance": str(color_analysis.average_distance).substr(0, 4), + "max_color_distance": color_analysis.max_distance, + # image paths + "reference_image_path": reference_image_path, + "captured_image_path": captured_image_path, + "difference_image_path": difference_image_path, + } + + return { + "node_name" : node_name, + "resolution": reference_image.get_size(), + "total_pixels": total_pixels + } + +func _calculate_pixel_difference(pixel1: Color, pixel2: Color, ignore_alpha: bool) -> float: + # Calculate euclidean distance between colors + var r_diff: float = abs(pixel1.r - pixel2.r) + var g_diff: float = abs(pixel1.g - pixel2.g) + var b_diff: float = abs(pixel1.b - pixel2.b) + var a_diff: float = abs(pixel1.a - pixel2.a) + if ignore_alpha: + a_diff = 0 + + return r_diff + g_diff + b_diff + a_diff + + #return sqrt(r_diff * r_diff + g_diff * g_diff + b_diff * b_diff + a_diff * a_diff) / 2.0 + + +# Analyze color difference statistics +func _analyze_color_differences(pixel_differences: Array[float]) -> Dictionary: + if pixel_differences.is_empty(): + return { + "average_distance": 0.0, + "max_distance": 0.0 + } + + var total_distance := 0.0 + var max_distance := 0.0 + + for distance in pixel_differences: + total_distance += distance + max_distance = max(max_distance, distance) + + var average_distance := total_distance / pixel_differences.size() + + return { + "average_distance": average_distance, + "max_distance": max_distance + } diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd index 5c852525b..2c5e01262 100644 --- a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd +++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd @@ -353,6 +353,32 @@ func get_screen_touch_drag_position(index: int) -> Vector2: return Vector2.ZERO +func capture_initial_screen_shot(source_node: Node, path: String) -> Image: + if FileAccess.file_exists(path): + var image := Image.new() + var err0 := image.load(ProjectSettings.globalize_path(path)) + if err0 == OK: + return image + push_warning("Error: '{0}' loading image from path '{1}'!".format([error_string(err0), path])) + + prints("No image resource found at '{0}', capture a fresh screenshot from component '{1}'".format([path, source_node.get_path()])) + if DisplayServer.get_name() == "headless": + push_error("Can't capture image from node {0} on 'headless' mode!".format([source_node.get_path()])) + return null + + if not DirAccess.dir_exists_absolute(path.get_base_dir()): + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + var captured_image := GdUnitUiTools.capture_image(source_node) + if captured_image == null: + push_error("Can't capture image from node {0}".format([source_node.get_path()])) + return null + var err1 := captured_image.save_png(path) + if err1 == OK: + return captured_image + push_warning("Error: '{0}', can't save captured image of {1} to path specified {2}.".format([error_string(err1), source_node.get_path(), path])) + return null + + func is_emulate_mouse_from_touch() -> bool: return ProjectSettings.get_setting("input_devices/pointing/emulate_mouse_from_touch", true) diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd index cc93ee2bc..0df71748b 100644 --- a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd +++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd @@ -17,6 +17,7 @@ enum { COLOR_RGB } +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") const CSI_BOLD = "" const CSI_ITALIC = "" const CSI_UNDERLINE = "" @@ -33,6 +34,13 @@ var _current_pos := 0 var _tag_regex: RegEx +func print_bbcode(message: String) -> void: + var text := GdUnitTools.richtext_normalize(message) + #use GdUnitUiTools to write the message + for line in text.split("\n", false): + indent(2).color(Color.DARK_TURQUOISE).println_message(line) + + ## Constructs CSI style codes based on flags.[br] ## [br] ## [param flags] The style flags to apply (BOLD, ITALIC, UNDERLINE).[br] diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd index 5244efa46..b394cdb07 100644 --- a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd +++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd @@ -169,6 +169,11 @@ func print_at(message: String, cursor_pos: int) -> void: reset() +## Prints a message using bbcode +func print_bbcode(_message: String) -> void: + pass + + ## Internal implementation of print_message.[br] ## [br] ## To be overridden by concrete formatters.[br] diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd index b9f79e49f..2fb05607e 100644 --- a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd +++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd @@ -28,6 +28,10 @@ func _init(output: RichTextLabel) -> void: _output = output +func print_bbcode(message: String) -> void: + GdUnitUiTools.set_report_message(_output, message) + + ## Applies text style flags by wrapping text in BBCode tags.[br] ## [br] ## Available styles:[br] diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd index 9978deb31..7c7c8ec2f 100644 --- a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd +++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd @@ -143,9 +143,7 @@ func _print_failure_report(reports: Array[GdUnitReport]) -> void: .color(Color.DARK_TURQUOISE) \ .style(GdUnitMessageWriter.BOLD | GdUnitMessageWriter.UNDERLINE) \ .println_message("Report:") - var text := str(report) - for line in text.split("\n", false): - _writer.indent(2).color(Color.DARK_TURQUOISE).println_message(line) + _writer.indent(1).print_bbcode(report.to_string()) if not reports.is_empty(): println_message("") diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd b/addons/gdUnit4/src/ui/GdUnitUiTools.gd index 0bcdb1d29..eaf7d2b7c 100644 --- a/addons/gdUnit4/src/ui/GdUnitUiTools.gd +++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd @@ -87,6 +87,48 @@ static func get_CSharpScript_icon(status: String, color: Color) -> Texture2D: return ImageTexture.create_from_image(image) +static func capture_image(node: Node) -> Image: + var image := node.get_viewport().get_texture().get_image() + if image == null: + return null + if node is Control: + # Crop to control bounds if needed + var rect := (node as Control).get_global_rect() + if rect.size.x > 0 and rect.size.y > 0: + image = image.get_region(rect) + + if node is TextureRect: + image = (node as TextureRect).texture.get_image() + + return image + + +static func set_report_message(reportNode: RichTextLabel, message: String) -> void: + var regex := RegEx.new() + regex.compile(r"\[img\](.*?)\[/img\]") + + var urls: PackedStringArray = [] + for result in regex.search_all(message): + var img_tag := result.get_string(0) + urls.append(result.get_string(1)) + message = message.replace(img_tag, "^") + + var image_index := 0 + reportNode.push_color(Color.DARK_TURQUOISE) + for text in message.split("^"): + reportNode.append_text(text) + reportNode.newline() + if image_index < urls.size(): + var image := Image.new() + image.load(urls[image_index]) + var texture_image := ImageTexture.create_from_image(image) + if texture_image != null: + reportNode.add_image(texture_image) + reportNode.newline() + image_index += 1 + reportNode.pop() + + static func _modulate_texture(texture: Texture2D, color: Color) -> Texture2D: var image := _modulate_image(texture.get_image(), color) return ImageTexture.create_from_image(image) diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd index 440c40c75..9913e264c 100644 --- a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd +++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd @@ -243,7 +243,7 @@ func cleanup_tree() -> void: _current_selected_item = null -func _free_recursive(items:=_tree_root.get_children()) -> void: +func _free_recursive(items := _tree_root.get_children()) -> void: for item in items: _free_recursive(item.get_children()) item.call_deferred("free") @@ -311,12 +311,12 @@ static func sort_items_by_execution_time(a: TreeItem, b: TreeItem) -> bool: if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: return false - var execution_time_a :int = a.get_meta(META_GDUNIT_EXECUTION_TIME) - var execution_time_b :int = b.get_meta(META_GDUNIT_EXECUTION_TIME) + var execution_time_a: int = a.get_meta(META_GDUNIT_EXECUTION_TIME) + var execution_time_b: int = b.get_meta(META_GDUNIT_EXECUTION_TIME) # if has same execution time sort by name if execution_time_a == execution_time_b: - var name_a :String = a.get_meta(META_GDUNIT_NAME) - var name_b :String = b.get_meta(META_GDUNIT_NAME) + var name_a: String = a.get_meta(META_GDUNIT_NAME) + var name_b: String = b.get_meta(META_GDUNIT_NAME) return name_a.naturalnocasecmp_to(name_b) > 0 return execution_time_a > execution_time_b @@ -331,8 +331,8 @@ static func sort_items_by_original_index(a: TreeItem, b: TreeItem) -> bool: if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: return false - var index_a :int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX) - var index_b :int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX) + var index_a: int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX) + var index_b: int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX) # Sorting by index return index_a < index_b @@ -694,7 +694,7 @@ func add_report(item: TreeItem, report: GdUnitReport) -> void: item.set_meta(META_GDUNIT_REPORT, reports) -func abort_running(items:=_tree_root.get_children()) -> void: +func abort_running(items := _tree_root.get_children()) -> void: for item in items: if item.get_icon(0) == ICON_SPINNER: set_state_aborted(item) @@ -746,9 +746,7 @@ func show_failed_report(selected_item: TreeItem) -> void: for report in get_item_reports(selected_item): var reportNode: RichTextLabel = _report_template.duplicate() _report_list.add_child(reportNode) - reportNode.push_color(Color.DARK_TURQUOISE) - reportNode.append_text(report.to_string()) - reportNode.pop() + GdUnitUiTools.set_report_message(reportNode, report.to_string()) reportNode.visible = true @@ -757,6 +755,7 @@ func update_test_suite(event: GdUnitEvent) -> void: if not item: push_error("[InspectorTreeMainPanel#update_test_suite] Internal Error: Can't find test suite item '{_suite_name}' for {_resource_path} ".format(event)) return + if event.type() == GdUnitEvent.TESTSUITE_AFTER: update_item_elapsed_time_counter(item, event.elapsed_time()) update_state(item, event) @@ -803,10 +802,10 @@ func create_item(parent: TreeItem, test: GdUnitTestCase, item_name: String, type return item -func set_item_icon_by_state(item :TreeItem) -> void: +func set_item_icon_by_state(item: TreeItem) -> void: if item == _tree_root: return - var state :STATE = item.get_meta(META_GDUNIT_STATE) + var state: STATE = item.get_meta(META_GDUNIT_STATE) var is_orphan := is_item_state_orphan(item) var resource_path := get_item_source_file(item) item.set_icon(0, get_icon_by_file_type(resource_path, state, is_orphan)) @@ -905,8 +904,8 @@ func update_item_elapsed_time_counter(item: TreeItem, time: int) -> void: var parent := item.get_parent() if parent == _tree_root: return - var elapsed_time :int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time - var type :GdUnitType = item.get_meta(META_GDUNIT_TYPE) + var elapsed_time: int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time + var type: GdUnitType = item.get_meta(META_GDUNIT_TYPE) match type: GdUnitType.TEST_CASE: return @@ -966,7 +965,7 @@ func on_test_case_discover_added(test_case: GdUnitTestCase) -> void: # Skip tree structure until test root folder var index := parts.find(test_root_folder) if index != -1: - parts = parts.slice(index+1) + parts = parts.slice(index + 1) match _current_tree_view_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT: @@ -1039,7 +1038,7 @@ func on_test_case_discover_deleted(test_case: GdUnitTestCase) -> void: var item_success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) var item_total_test_count: int = item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) var total_test_count: int = parent.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) - parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count-item_total_test_count) + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count - item_total_test_count) # propagate counter update to all parents update_item_total_counter(parent) @@ -1086,7 +1085,7 @@ func _dump_tree_as_json(dump_name: String) -> void: file.store_string(JSON.stringify(dict, "\t")) -func _to_json(parent :TreeItem) -> Dictionary: +func _to_json(parent: TreeItem) -> Dictionary: var item_as_dict := GdObjects.obj2dict(parent) item_as_dict["TreeItem"]["childrens"] = parent.get_children().map(func(item: TreeItem) -> Dictionary: return _to_json(item)) @@ -1122,7 +1121,7 @@ func test_session_stop() -> void: # wait until the tree redraw await get_tree().process_frame var failure_item := _find_first_item_by_state(_tree_root, STATE.FAILED) - select_item( failure_item if failure_item else _current_selected_item) + select_item(failure_item if failure_item else _current_selected_item) ################################################################################ @@ -1200,7 +1199,7 @@ func _on_gdunit_event(event: GdUnitEvent) -> void: await test_session_stop() -func _on_settings_changed(property :GdUnitProperty) -> void: +func _on_settings_changed(property: GdUnitProperty) -> void: match property.name(): GdUnitSettings.INSPECTOR_TREE_SORT_MODE: sort_tree_items(_tree_root) diff --git a/addons/gdUnit4/test/asserts/GdUnitUiAssertImplTest.gd b/addons/gdUnit4/test/asserts/GdUnitUiAssertImplTest.gd new file mode 100644 index 000000000..08c273a3d --- /dev/null +++ b/addons/gdUnit4/test/asserts/GdUnitUiAssertImplTest.gd @@ -0,0 +1,37 @@ +extends GdUnitTestSuite + + +const UIConfig = GdUnitUIAssert.GdUnitUIConfig + + +func test_is_equal_screenshot_strict() -> void: + var reference_image :CompressedTexture2D = load("res://addons/gdUnit4/test/resources/assets/ycm-gradient.png") + var node := await create_example_node("res://addons/gdUnit4/test/resources/assets/ycm-gradient.png", reference_image.get_size()) + + assert_ui(node).is_equal_screenshot(reference_image.get_image(), UIConfig.strict()) + + +func test_is_equal_screenshot_strict_with_diff() -> void: + var reference_image :CompressedTexture2D = load("res://addons/gdUnit4/test/resources/assets/ycm-gradient.png") + var node := await create_example_node("res://addons/gdUnit4/test/resources/assets/ycm-gradient_additions.png", reference_image.get_size()) + + assert_ui(node).is_equal_screenshot(reference_image.get_image(), UIConfig.anti_aliasing()) + + +func test_is_equal_screenshot_strict_with_alpha() -> void: + var reference_image :CompressedTexture2D = load("res://addons/gdUnit4/test/resources/assets/pacman.png") + var node := await create_example_node("res://addons/gdUnit4/test/resources/assets/pacman.png", reference_image.get_size()) + + + assert_ui(node).is_equal_screenshot(reference_image.get_image(), UIConfig.strict().ignore_alpha()) + + +func create_example_node(image_path: String, minimum_size: Vector2i) -> Node: + var node: Control = auto_free(Control.new()) + node.custom_minimum_size = minimum_size + var trect := TextureRect.new() + trect.texture = load(image_path) + node.add_child(trect) + add_child(node) + await await_millis(100) + return node diff --git a/addons/gdUnit4/test/core/GdUnitSceneRunnerTest.gd b/addons/gdUnit4/test/core/GdUnitSceneRunnerTest.gd index 32ba659ca..c531b5c5e 100644 --- a/addons/gdUnit4/test/core/GdUnitSceneRunnerTest.gd +++ b/addons/gdUnit4/test/core/GdUnitSceneRunnerTest.gd @@ -47,7 +47,7 @@ func test_invoke_method() -> void: @warning_ignore("unused_parameter") func test_simulate_frames(timeout := 5000) -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") # initial is white assert_object(box1.color).is_equal(Color.WHITE) @@ -68,7 +68,7 @@ func test_simulate_frames(timeout := 5000) -> void: @warning_ignore("unused_parameter") func test_simulate_frames_withdelay(timeout := 4000) -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") # initial is white assert_object(box1.color).is_equal(Color.WHITE) @@ -84,7 +84,7 @@ func test_simulate_frames_withdelay(timeout := 4000) -> void: @warning_ignore("unused_parameter") func test_run_scene_colorcycle(timeout := 2000) -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") # verify inital color assert_object(box1.color).is_equal(Color.WHITE) @@ -140,7 +140,7 @@ func test_simulate_scene_inteaction_in_combination_with_spy() -> void: func test_simulate_scene_interact_with_buttons() -> void: - var spyed_scene :Variant = spy("res://addons/gdUnit4/test/mocker/resources/scenes/TestScene.tscn") + var spyed_scene: Variant = spy("res://addons/gdUnit4/test/mocker/resources/scenes/TestScene.tscn") var runner := scene_runner(spyed_scene) # test button 1 interaction await await_millis(1000) @@ -195,7 +195,7 @@ func test_await_func_with_time_factor() -> void: func test_await_signal_without_time_factor() -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") runner.invoke("start_color_cycle") await runner.await_signal("panel_color_change", [box1, Color.RED]) @@ -203,14 +203,14 @@ func test_await_signal_without_time_factor() -> void: await runner.await_signal("panel_color_change", [box1, Color.GREEN]) ( # should be interrupted is will never change to Color.KHAKI - await assert_failure_await(func x() -> void: await runner.await_signal( "panel_color_change", [box1, Color.KHAKI], 300)) - ).has_message("await_signal_on(panel_color_change, [%s, %s]) timed out after 300ms" % [str(box1), str(Color.KHAKI)])\ + await assert_failure_await(func x() -> void: await runner.await_signal("panel_color_change", [box1, Color.KHAKI], 300)) + ).has_message("await_signal_on(panel_color_change, [%s, %s]) timed out after 300ms" % [str(box1), str(Color.KHAKI)]) \ .has_line(206) func test_await_signal_with_time_factor() -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") # set max time factor to minimize waiting time checked `runner.wait_func` runner.set_time_factor(10) runner.invoke("start_color_cycle") @@ -221,13 +221,13 @@ func test_await_signal_with_time_factor() -> void: ( # should be interrupted is will never change to Color.KHAKI await assert_failure_await(func x() -> void: await runner.await_signal("panel_color_change", [box1, Color.KHAKI], 30)) - ).has_message("await_signal_on(panel_color_change, [%s, %s]) timed out after 30ms" % [str(box1), str(Color.KHAKI)])\ + ).has_message("await_signal_on(panel_color_change, [%s, %s]) timed out after 30ms" % [str(box1), str(Color.KHAKI)]) \ .has_line(223) func test_simulate_until_signal() -> void: var runner := scene_runner(load_test_scene()) - var box1 :ColorRect = runner.get_property("_box1") + var box1: ColorRect = runner.get_property("_box1") # set max time factor to minimize waiting time checked `runner.wait_func` runner.invoke("start_color_cycle") @@ -258,7 +258,7 @@ func test_simulate_until_object_signal(timeout := 2000) -> void: func test_runner_by_null_instance() -> void: - var runner :GdUnitSceneRunnerImpl = scene_runner(null) + var runner: GdUnitSceneRunnerImpl = scene_runner(null) assert_object(runner._current_scene).is_null() @@ -327,7 +327,7 @@ func test_runner_by_invalid_scene_instance() -> void: func test_runner_by_scene_instance() -> void: - var scene :Node = load("res://addons/gdUnit4/test/core/resources/scenes/simple_scene.tscn").instantiate() + var scene: Node = load("res://addons/gdUnit4/test/core/resources/scenes/simple_scene.tscn").instantiate() var runner := scene_runner(scene) assert_object(runner.scene()).is_instanceof(Node2D) @@ -342,11 +342,11 @@ func test_runner_by_scene_instance() -> void: func test_mouse_drag_and_drop() -> void: - var spy_scene :Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") + var spy_scene: Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") var runner := scene_runner(spy_scene) - var slot_left :TextureRect = $"/root/DragAndDropScene/left/TextureRect" - var slot_right :TextureRect = $"/root/DragAndDropScene/right/TextureRect" + var slot_left: TextureRect = $"/root/DragAndDropScene/left/TextureRect" + var slot_right: TextureRect = $"/root/DragAndDropScene/right/TextureRect" var save_mouse_pos := get_tree().root.get_mouse_position() # set inital mouse pos over the left slot @@ -366,7 +366,7 @@ func test_mouse_drag_and_drop() -> void: # start drag&drop to left pannel for i in 20: - runner.simulate_mouse_move(mouse_pos + Vector2(i*.4*i, 0)) + runner.simulate_mouse_move(mouse_pos + Vector2(i * .4 * i, 0)) await await_millis(40) runner.simulate_mouse_button_release(MOUSE_BUTTON_LEFT) @@ -375,11 +375,11 @@ func test_mouse_drag_and_drop() -> void: func test_touch_drag_and_drop_relative() -> void: - var spy_scene :Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") + var spy_scene: Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") var runner := scene_runner(spy_scene) - var slot_left :TextureRect = $"/root/DragAndDropScene/left/TextureRect" - var slot_right :TextureRect = $"/root/DragAndDropScene/right/TextureRect" + var slot_left: TextureRect = $"/root/DragAndDropScene/left/TextureRect" + var slot_right: TextureRect = $"/root/DragAndDropScene/right/TextureRect" var drag_start := slot_left.global_position + Vector2(50, 50) # set inital mouse pos over the left touch button @@ -401,11 +401,11 @@ func test_touch_drag_and_drop_relative() -> void: func test_touch_drag_and_drop_absolute() -> void: - var spy_scene :Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") + var spy_scene: Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") var runner := scene_runner(spy_scene) - var slot_left :TextureRect = $"/root/DragAndDropScene/left/TextureRect" - var slot_right :TextureRect = $"/root/DragAndDropScene/right/TextureRect" + var slot_left: TextureRect = $"/root/DragAndDropScene/left/TextureRect" + var slot_right: TextureRect = $"/root/DragAndDropScene/right/TextureRect" var drag_start := slot_left.global_position + Vector2(50, 50) # set inital mouse pos over the left touch button @@ -418,7 +418,7 @@ func test_touch_drag_and_drop_absolute() -> void: #await await_millis(1000) # start drag&drop to right touch button - var drag_end := Vector2(drag_start.x+140, drag_start.y) + var drag_end := Vector2(drag_start.x + 140, drag_start.y) await runner.simulate_screen_touch_drag_absolute(0, drag_end) # verify assert_that(slot_right.texture).is_equal(slot_left.texture) @@ -427,14 +427,14 @@ func test_touch_drag_and_drop_absolute() -> void: func test_touch_drag_and_drop() -> void: - var spy_scene :Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") + var spy_scene: Variant = spy("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") var runner := scene_runner(spy_scene) - var slot_left :TextureRect = $"/root/DragAndDropScene/left/TextureRect" - var slot_right :TextureRect = $"/root/DragAndDropScene/right/TextureRect" + var slot_left: TextureRect = $"/root/DragAndDropScene/left/TextureRect" + var slot_right: TextureRect = $"/root/DragAndDropScene/right/TextureRect" var drag_start := slot_left.global_position + Vector2(50, 50) # start drag&drop to right touch button - var drag_end := Vector2(drag_start.x+140, drag_start.y) + var drag_end := Vector2(drag_start.x + 140, drag_start.y) await runner.simulate_screen_touch_drag_drop(0, drag_start, drag_end) # verify assert_that(slot_right.texture).is_equal(slot_left.texture) @@ -480,6 +480,32 @@ func test_load_scene_with_audio_player() -> void: await runner.simulate_frames(1) +const UIConfig = GdUnitUIAssert.GdUnitUIConfig + + +func test_is_equal_screenshot() -> void: + var runner := scene_runner("res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/DragAndDropTestScene.tscn") + # get left slot node + var slot_node: TextureRect = $"/root/DragAndDropScene/left/TextureRect" + await runner.simulate_frames(5) + + # load comparing image + var image_ref_valid := runner.capture_initial_screen_shot(slot_node, "res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left.png") + # Verify, the reference image is matching the current component + assert_ui(slot_node).is_equal_screenshot(image_ref_valid, UIConfig.strict()) + + var image_ref_invalid := runner.capture_initial_screen_shot(slot_node, "res://addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left_has_differences.png") + # Verify, the reference image is NOT matching the current component + assert_failure(func() -> void: + assert_ui(slot_node).is_equal_screenshot(image_ref_invalid, UIConfig.strict()) + ) \ + .is_failed() \ + .contains_message("Visual regression detected in 'TextureRect':") \ + .contains_message("Node Path: /root/DragAndDropScene/left/") \ + .contains_message("Resolution: (105, 105) pixels") \ + .contains_message("- 89 pixels of total 11025 differs (0.80% of total)") + + # we override the scene runner function for test purposes to hide push_error notifications -func scene_runner(scene :Variant, verbose := false) -> GdUnitSceneRunner: +func scene_runner(scene: Variant, verbose := false) -> GdUnitSceneRunner: return auto_free(GdUnitSceneRunnerImpl.new(scene, verbose, true)) diff --git a/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left.png b/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left.png new file mode 100644 index 000000000..76b0a7a28 Binary files /dev/null and b/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left.png differ diff --git a/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left_has_differences.png b/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left_has_differences.png new file mode 100644 index 000000000..a115e4744 Binary files /dev/null and b/addons/gdUnit4/test/core/resources/scenes/drag_and_drop/reference_images/slot_left_has_differences.png differ diff --git a/addons/gdUnit4/test/resources/assets/pacman.png b/addons/gdUnit4/test/resources/assets/pacman.png new file mode 100644 index 000000000..be0ec5aae Binary files /dev/null and b/addons/gdUnit4/test/resources/assets/pacman.png differ diff --git a/addons/gdUnit4/test/resources/assets/ycm-gradient.png b/addons/gdUnit4/test/resources/assets/ycm-gradient.png new file mode 100644 index 000000000..9d93e6e75 Binary files /dev/null and b/addons/gdUnit4/test/resources/assets/ycm-gradient.png differ diff --git a/addons/gdUnit4/test/resources/assets/ycm-gradient_additions.png b/addons/gdUnit4/test/resources/assets/ycm-gradient_additions.png new file mode 100644 index 000000000..d4e31895e Binary files /dev/null and b/addons/gdUnit4/test/resources/assets/ycm-gradient_additions.png differ diff --git a/test_img/test.png b/test_img/test.png new file mode 100644 index 000000000..a51e1d7ed Binary files /dev/null and b/test_img/test.png differ diff --git a/toolsScene.tscn b/toolsScene.tscn new file mode 100644 index 000000000..06941085e --- /dev/null +++ b/toolsScene.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=2 format=3 uid="uid://csdesvvxk0tby"] + +[sub_resource type="GDScript" id="GDScript_6ygey"] +script/source = "extends Node2D + +@onready var rtl :RichTextLabel = %RichTextLabel + +func _ready() -> void: + var captured_image := Image.new() + captured_image.load(ProjectSettings.globalize_path(\"res://icon.png\")) + + # Delete previous created image imports + DirAccess.remove_absolute(\"res://test_img/test.png\") + DirAccess.remove_absolute(\"res://test_img/test.png.import\") + + # Save new image + captured_image.save_png(\"res://test_img/test.png\") + + # Try to force import the image + var import_image := Image.new() + import_image.take_over_path(\"res://test_img/test.png\") + # we need to load the image by this function + import_image = Image.load_from_file(\"res://test_img/test.png\") + + var t := ImageTexture.create_from_image(import_image) + + # Set image to the RichTextLabel + var use_builder := true + if use_builder: + rtl.clear() + rtl.append_text(\"--- [b]Image[/b] --\") + rtl.newline() + rtl.add_image(t) + rtl.newline() + rtl.append_text(\"-------------------\") + else: + rtl.parse_bbcode(\"\"\" + --- [b]Image[/b] -- + [img]res://test_img/test.png[/img] + ------------------- + \"\"\") +" + +[node name="ToolsScene" type="Node2D"] +script = SubResource("GDScript_6ygey") + +[node name="RichTextLabel" type="RichTextLabel" parent="."] +unique_name_in_owner = true +custom_minimum_size = Vector2(200, 200) +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = 1142.0 +offset_bottom = 631.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +bbcode_enabled = true +text = "test" +scroll_active = false