diff --git a/scripts/main_scene.gd b/scripts/main_scene.gd index 10275848..13afd0cb 100644 --- a/scripts/main_scene.gd +++ b/scripts/main_scene.gd @@ -9,12 +9,14 @@ extends Node2D enum MessageType { CRITICAL_UNBOUND, KEY_PRESS_UNBOUND } +# At the top of main_scene.gd +@export var parallax_screens_tall: float = 8.0 + var _showing_unbound_warning: bool = false var _showing_unbound_key_message: bool = false @onready var player: Node2D = $Player -# @onready var stats_panel: Panel = $PlayerStatsPanel -@onready var stats_panel: Variant = $PlayerStatsPanel +@onready var stats_panel: Panel = $PlayerStatsPanel @onready var background: ParallaxBackground = $Background @onready var bushes_layer: ParallaxLayer = $Background/Bushes # Reference to the bushes layer @onready var decor_layer: ParallaxLayer = $Background/Decor # Reference to the decor layer @@ -116,22 +118,25 @@ func setup_bushes_layer(viewport: Vector2) -> void: if not bushes_layer: return - # Clear existing children + # Clear existing children (Safely detach first, then instantly destroy) for child in bushes_layer.get_children(): bushes_layer.remove_child(child) - child.queue_free() + child.free() # Get bush IDs from preloader (Array[String]) var bush_ids: Array = Array(texture_preloader.get_resource_list()).filter( func(id: String) -> bool: return id.begins_with("bush_") ) - print("Loaded ", bush_ids.size(), " bush textures") if bush_ids.is_empty(): return - var num_bushes: int = bush_ids.size() - var layer_height: float = viewport.y * 4 + # THE GOLDILOCKS ZONE: + # 8 screens is the sweet spot for infinite illusion vs CPU overhead + var layer_height: float = viewport.y * parallax_screens_tall + + # Drop density multiplier to match + var num_bushes: int = bush_ids.size() * 2 for i in range(num_bushes): var bush: Sprite2D = Sprite2D.new() @@ -152,29 +157,35 @@ func setup_bushes_layer(viewport: Vector2) -> void: bushes_layer.motion_mirroring = Vector2(0, layer_height) -## Sets up the decor layer with random X positions, sizes, and textures. +## Sets up the decor layer with random X positions, sizes, textures, rotations, and flips. ## @param viewport: Vector2 - The viewport size. ## @return: void func setup_decor_layer(viewport: Vector2) -> void: if not decor_layer: return - # Clear existing children + # Clear existing children (Safely detach first, then instantly destroy) for child in decor_layer.get_children(): decor_layer.remove_child(child) - child.queue_free() + child.free() # Get decor IDs from preloader (Array[String]) var decor_ids: Array = Array(texture_preloader.get_resource_list()).filter( func(id: String) -> bool: return id.begins_with("decor_") ) - print("Loaded ", decor_ids.size(), " decor textures") if decor_ids.is_empty(): return - var num_decors: int = decor_ids.size() - var layer_height: float = viewport.y * 4 + # THE GOLDILOCKS ZONE: + # Match the bushes layer height + var layer_height: float = viewport.y * parallax_screens_tall + + # Drop density multiplier to match + var num_decors: int = decor_ids.size() * 2 + + # Define strict rotation angles (0, 90, 180, -90 degrees) + var allowed_rotations: Array[float] = [0.0, 90.0, 180.0, -90.0] for i in range(num_decors): var decor: Sprite2D = Sprite2D.new() @@ -183,9 +194,18 @@ func setup_decor_layer(viewport: Vector2) -> void: decor.texture = texture_preloader.get_resource(id) decor.centered = false - var scale_factor: float = randf_range(0.5, 1.0) + # SCALING TRICK 1: Wider scale range (0.5 to 1.5) for more size variance + var scale_factor: float = randf_range(0.5, 1.5) decor.scale = Vector2(scale_factor, scale_factor) + # SCALING TRICK 2: Randomly mirror the sprite horizontally and/or vertically + decor.flip_h = randf() < 0.5 + decor.flip_v = randf() < 0.5 + + # Apply random cardinal rotation to ALL decor sprites + var random_degrees: float = allowed_rotations.pick_random() + decor.rotation = deg_to_rad(random_degrees) + decor.position.x = randf_range(0, viewport.x - (decor.texture.get_width() * scale_factor)) decor.position.y = randf_range( 0, layer_height - (decor.texture.get_height() * scale_factor) @@ -248,10 +268,6 @@ func show_message(text: String, type: MessageType = MessageType.CRITICAL_UNBOUND match type: MessageType.KEY_PRESS_UNBOUND: _showing_unbound_key_message = false - # CRITICAL_UNBOUND: Do NOT reset here (once-per-session intent) - # Reset only when bindings are fixed - # (e.g., in key_mapping.gd _on_conflict_confirmed or reset) - # _showing_unbound_warning = false # ← commented out ## Public: Clears the unbound warning flag after fixes. diff --git a/test/gut/gut_test_helper.gd b/test/gut/gut_test_helper.gd index 954d0014..3466c3f3 100644 --- a/test/gut/gut_test_helper.gd +++ b/test/gut/gut_test_helper.gd @@ -6,6 +6,19 @@ extends RefCounted const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd" + +## Helper to safely hard-free a node without causing engine crashes. +## Asserts the node is valid and safe to instantly destroy. +static func safe_hard_free(node: Node) -> void: + if not is_instance_valid(node) or node.is_queued_for_deletion(): + return + + if node.is_inside_tree(): + node.get_parent().remove_child(node) + + node.free() + + ## Dynamically constructs the node hierarchy required by player.gd. ## :rtype: Node static func build_mock_player_scene() -> Node: diff --git a/test/gut/test_decor_layer_transformations.gd b/test/gut/test_decor_layer_transformations.gd new file mode 100644 index 00000000..ce826de9 --- /dev/null +++ b/test/gut/test_decor_layer_transformations.gd @@ -0,0 +1,131 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_decor_layer_transformations.gd +## +## GUT unit tests for verifying randomized rotations, scaling, +## and flipping applied to the main scene's decor layer. + +extends "res://addons/gut/test.gd" + +const GutHelper = preload("res://test/gut/gut_test_helper.gd") + +var main_scene: MainScene +var viewport_mock: Vector2 = Vector2(1920, 1080) + + +func before_each() -> void: + await get_tree().process_frame + main_scene = preload("res://scenes/main_scene.tscn").instantiate() + add_child(main_scene) + await get_tree().process_frame + +func after_each() -> void: + # Use the helper's static method + if is_instance_valid(main_scene): + GutHelper.safe_hard_free(main_scene) + await get_tree().process_frame + + +## test_decor_sprites_have_valid_cardinal_rotations | +## Verifies every decor sprite is snapped to exactly 0, 90, 180, or -90 degrees. +## :rtype: void +func test_decor_sprites_have_valid_cardinal_rotations() -> void: + gut.p("Testing: All decor sprites must use strict cardinal rotations.") + + main_scene.setup_decor_layer(viewport_mock) + + var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ) + + var allowed_radians: Array[float] = [ + 0.0, + deg_to_rad(90.0), + deg_to_rad(180.0), + deg_to_rad(-90.0) + ] + + var invalid_rotations: Array[float] = [] + + for node in active_sprites: + var sprite := node as Sprite2D + var is_valid := false + + # Check if the sprite's rotation matches any allowed radian (using approx to handle float drift) + for allowed_rad in allowed_radians: + if is_equal_approx(sprite.rotation, allowed_rad): + is_valid = true + break + + if not is_valid: + invalid_rotations.append(sprite.rotation) + + assert_eq(invalid_rotations.size(), 0, "Found decor sprites with non-cardinal rotations.") + + +## test_decor_sprites_have_valid_scale_ranges | +## Verifies every decor sprite scale falls between 0.5 and 1.5, and is uniformly scaled. +## :rtype: void +func test_decor_sprites_have_valid_scale_ranges() -> void: + gut.p("Testing: All decor sprites must be uniformly scaled between 0.5 and 1.5.") + + main_scene.setup_decor_layer(viewport_mock) + + var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ) + + var out_of_bounds_scales: Array[Vector2] = [] + var non_uniform_scales: Array[Vector2] = [] + + for node in active_sprites: + var sprite := node as Sprite2D + var s: Vector2 = sprite.scale + + # Check bounds (using 0.49 and 1.51 to safely absorb floating point precision errors) + if s.x < 0.49 or s.x > 1.51: + out_of_bounds_scales.append(s) + + # Check uniformity (x scale must equal y scale) + if not is_equal_approx(s.x, s.y): + non_uniform_scales.append(s) + + assert_eq(out_of_bounds_scales.size(), 0, "Found decor sprites outside the 0.5 - 1.5 scale range.") + assert_eq(non_uniform_scales.size(), 0, "Found decor sprites with non-uniform (squished/stretched) scaling.") + + +## test_decor_sprites_have_boolean_flips | +## Verifies that flip_h and flip_v properties are actively being randomized. +## :rtype: void +func test_decor_sprites_have_boolean_flips() -> void: + gut.p("Testing: Decor sprites should successfully randomize horizontal and vertical flips.") + + main_scene.setup_decor_layer(viewport_mock) + + var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ) + + var found_h_true: bool = false + var found_h_false: bool = false + var found_v_true: bool = false + var found_v_false: bool = false + + for node in active_sprites: + var sprite := node as Sprite2D + + # Track horizontal flip states + if sprite.flip_h: + found_h_true = true + else: + found_h_false = true + + # Track vertical flip states + if sprite.flip_v: + found_v_true = true + else: + found_v_false = true + + # Assert that our generation loop produced at least one of every state + assert_true(found_h_true and found_h_false, "Expected a randomized mix of true and false for horizontal flips.") + assert_true(found_v_true and found_v_false, "Expected a randomized mix of true and false for vertical flips.") diff --git a/test/gut/test_decor_layer_transformations.gd.uid b/test/gut/test_decor_layer_transformations.gd.uid new file mode 100644 index 00000000..5ab94d22 --- /dev/null +++ b/test/gut/test_decor_layer_transformations.gd.uid @@ -0,0 +1 @@ +uid://dn1cexpx24og0 diff --git a/test/gut/test_main_scene_orphan_nodes.gd b/test/gut/test_main_scene_orphan_nodes.gd new file mode 100644 index 00000000..2c644b6f --- /dev/null +++ b/test/gut/test_main_scene_orphan_nodes.gd @@ -0,0 +1,103 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_main_scene_orphan_nodes.gd + +extends "res://addons/gut/test.gd" + +const GutHelper = preload("res://test/gut/gut_test_helper.gd") + +var main_scene: MainScene +var viewport_mock: Vector2 = Vector2(1920, 1080) + +func before_each() -> void: + await get_tree().process_frame + main_scene = preload("res://scenes/main_scene.tscn").instantiate() + add_child(main_scene) + await get_tree().process_frame + +func after_each() -> void: + # Use the helper's static method + if is_instance_valid(main_scene): + GutHelper.safe_hard_free(main_scene) + await get_tree().process_frame + +func verify_no_orphan_leaks(baseline_orphans: int, context: String) -> void: + var current_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + assert_eq(current_orphans, baseline_orphans, context) + +func test_teardown_memory_sync() -> void: + var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + + main_scene.setup_bushes_layer(viewport_mock) + main_scene.setup_decor_layer(viewport_mock) + + await get_tree().process_frame + + # FIX: Free manually AND nullify so after_each ignores it + GutHelper.safe_hard_free(main_scene) + main_scene = null + + await get_tree().process_frame + verify_no_orphan_leaks(baseline_orphans, "Expected orphans to return to baseline.") + +func test_repeated_setup_call_stability() -> void: + var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + for i in range(50): + main_scene.setup_bushes_layer(viewport_mock) + await get_tree().process_frame + verify_no_orphan_leaks(baseline_orphans, "No accumulated orphans after 50 calls.") + +## Immediate Rebuild Integrity Test | +func test_immediate_rebuild_integrity() -> void: + await get_tree().process_frame + main_scene.setup_bushes_layer(viewport_mock) + + # Added -> bool to the lambda + var initial_active_count: int = main_scene.bushes_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + main_scene.setup_bushes_layer(viewport_mock) + + # Added -> bool to the lambda + var new_active_count: int = main_scene.bushes_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + assert_eq(new_active_count, initial_active_count, "Child count should remain stable.") + +func test_layer_isolation() -> void: + main_scene.setup_bushes_layer(viewport_mock) + main_scene.setup_decor_layer(viewport_mock) + await get_tree().process_frame + + var initial_decor: int = main_scene.decor_layer.get_child_count() + main_scene.setup_bushes_layer(viewport_mock) + await get_tree().process_frame + assert_eq(main_scene.decor_layer.get_child_count(), initial_decor, "Decor count stable.") + +func test_scene_reload_lifecycle() -> void: + var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + + # FIX: Use safe_hard_free and nullify baseline scene + GutHelper.safe_hard_free(main_scene) + main_scene = null + await get_tree().process_frame + + var reloaded_scene: MainScene = preload("res://scenes/main_scene.tscn").instantiate() + add_child(reloaded_scene) + reloaded_scene.setup_bushes_layer(viewport_mock) + await get_tree().process_frame + + # Clean up the reloaded instance manually too + GutHelper.safe_hard_free(reloaded_scene) + await get_tree().process_frame + verify_no_orphan_leaks(baseline_orphans, "Clean teardown after reload.") + +func test_stress_input_simulation() -> void: + var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + for i in range(10): + main_scene.setup_bushes_layer(viewport_mock) + main_scene.setup_decor_layer(viewport_mock) + await get_tree().process_frame + verify_no_orphan_leaks(baseline_orphans, "Stable memory after stress.") diff --git a/test/gut/test_main_scene_orphan_nodes.gd.uid b/test/gut/test_main_scene_orphan_nodes.gd.uid new file mode 100644 index 00000000..5b537f02 --- /dev/null +++ b/test/gut/test_main_scene_orphan_nodes.gd.uid @@ -0,0 +1 @@ +uid://c63067f3sp0l4 diff --git a/test/gut/test_main_scene_parallax_and_performance.gd b/test/gut/test_main_scene_parallax_and_performance.gd new file mode 100644 index 00000000..66f2c6c8 --- /dev/null +++ b/test/gut/test_main_scene_parallax_and_performance.gd @@ -0,0 +1,190 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_main_scene_performance_limits.gd +## +## GUT unit tests for enforcing performance constraints on the MainScene +## to prevent BVH bloat and FPS drops. + +extends "res://addons/gut/test.gd" + +# Use your existing helper +const GutHelper = preload("res://test/gut/gut_test_helper.gd") + +var main_scene: MainScene +var viewport_mock: Vector2 = Vector2(1920, 1080) + + +func before_each() -> void: + await get_tree().process_frame + main_scene = preload("res://scenes/main_scene.tscn").instantiate() + add_child(main_scene) + await get_tree().process_frame + + +func after_each() -> void: + # Use the helper's static method + if is_instance_valid(main_scene): + GutHelper.safe_hard_free(main_scene) + await get_tree().process_frame + + +## test_parallax_chunk_size_is_optimized | +## Enforces the 8-screen limit to prevent Bounding Volume Hierarchy (BVH) bloat. +## :rtype: void +func test_parallax_chunk_size_is_optimized() -> void: + gut.p("Testing: Parallax layers must not exceed an 8-screen height limit.") + + main_scene.setup_bushes_layer(viewport_mock) + main_scene.setup_decor_layer(viewport_mock) + + # The absolute maximum allowed height multiplier is 8.0 + var max_allowed_height: float = viewport_mock.y * 8.0 + + assert_eq( + main_scene.bushes_layer.motion_mirroring.y, + max_allowed_height, + "Bushes layer chunk size exceeds the 8-screen performance limit." + ) + + assert_eq( + main_scene.decor_layer.motion_mirroring.y, + max_allowed_height, + "Decor layer chunk size exceeds the 8-screen performance limit." + ) + + +## test_parallax_sprite_density_is_optimized | +## Enforces the 2x sprite density multiplier to prevent draw call explosions. +## :rtype: void +func test_parallax_sprite_density_is_optimized() -> void: + gut.p("Testing: Parallax sprite density must be strictly 2x the preloaded resource count.") + + main_scene.setup_bushes_layer(viewport_mock) + main_scene.setup_decor_layer(viewport_mock) + + # --- Check Bushes --- + var bush_ids: Array = Array(main_scene.texture_preloader.get_resource_list()).filter( + func(id: String) -> bool: return id.begins_with("bush_") + ) + var expected_bush_count: int = bush_ids.size() * 2 + + var active_bushes: int = main_scene.bushes_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + assert_eq( + active_bushes, + expected_bush_count, + "Bushes density multiplier must remain at 2x for performance stability." + ) + + # --- Check Decor --- + var decor_ids: Array = Array(main_scene.texture_preloader.get_resource_list()).filter( + func(id: String) -> bool: return id.begins_with("decor_") + ) + var expected_decor_count: int = decor_ids.size() * 2 + + var active_decor: int = main_scene.decor_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + assert_eq( + active_decor, + expected_decor_count, + "Decor density multiplier must remain at 2x for performance stability." + ) + + +## test_process_script_execution_time | +## A proxy performance test to ensure the _process block runs in under 1 millisecond. +## :rtype: void +func test_process_script_execution_time() -> void: + gut.p("Testing: MainScene._process execution must remain lightweight (under 1ms).") + + # Pre-warm: absorb any one-shot work (e.g. show_message on first call). + main_scene._process(0.016) + + var iterations: int = 60 + var start_time: int = Time.get_ticks_usec() + for i in range(iterations): + main_scene._process(0.016) + var total_time_usec: int = Time.get_ticks_usec() - start_time + var average_time_per_frame_usec: float = float(total_time_usec) / iterations + + # 2000 µs = 2 ms — loose bound to avoid CI flakiness while still catching + # regressions (the profiler screenshot shows ~9 ms total SceneTree process, + # so the script-only portion is well under 1 ms on a local machine). + assert_lt( + average_time_per_frame_usec, + 2000.0, + "MainScene._process is taking too long. Look for expensive operations in _process." + ) + +## test_bushes_layer_chunk_size_and_density | +## Verify the bushes layer mirrors at exactly 8 screens tall and spawns 2x the sprites. +## :rtype: void +func test_bushes_layer_chunk_size_and_density() -> void: + gut.p("Testing: Bushes layer should use an 8-screen chunk size and 2x density.") + + # 1. Re-run setup to use our specific mock viewport + main_scene.setup_bushes_layer(viewport_mock) + + # 2. Verify Chunk Size (Height) + var expected_height: float = viewport_mock.y * 8.0 + assert_eq( + main_scene.bushes_layer.motion_mirroring.y, + expected_height, + "Bushes layer mirroring should be exactly 8 screens tall." + ) + + # 3. Calculate Expected Density based on the Preloader + var bush_ids: Array = Array(main_scene.texture_preloader.get_resource_list()).filter( + func(id: String) -> bool: return id.begins_with("bush_") + ) + var expected_count: int = bush_ids.size() * 2 + + # 4. Count only active nodes (filtering out anything queued for deletion from the _ready call) + var active_children: int = main_scene.bushes_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + assert_eq( + active_children, + expected_count, + "Bushes layer should spawn exactly 2 times the number of available bush sprites." + ) + + +## test_decor_layer_chunk_size_and_density | +## Verify the decor layer mirrors at exactly 8 screens tall and spawns 2x the sprites. +## :rtype: void +func test_decor_layer_chunk_size_and_density() -> void: + gut.p("Testing: Decor layer should use an 8-screen chunk size and 2x density.") + + # 1. Re-run setup + main_scene.setup_decor_layer(viewport_mock) + + # 2. Verify Chunk Size (Height) + var expected_height: float = viewport_mock.y * 8.0 + assert_eq( + main_scene.decor_layer.motion_mirroring.y, + expected_height, + "Decor layer mirroring should be exactly 8 screens tall." + ) + + # 3. Calculate Expected Density based on the Preloader + var decor_ids: Array = Array(main_scene.texture_preloader.get_resource_list()).filter( + func(id: String) -> bool: return id.begins_with("decor_") + ) + var expected_count: int = decor_ids.size() * 2 + + # 4. Filter out queued nodes + var active_children: int = main_scene.decor_layer.get_children().filter( + func(c: Node) -> bool: return not c.is_queued_for_deletion() + ).size() + + assert_eq( + active_children, + expected_count, + "Decor layer should spawn exactly 2 times the number of available decor sprites." + ) diff --git a/test/gut/test_main_scene_parallax_and_performance.gd.uid b/test/gut/test_main_scene_parallax_and_performance.gd.uid new file mode 100644 index 00000000..7b741cbd --- /dev/null +++ b/test/gut/test_main_scene_parallax_and_performance.gd.uid @@ -0,0 +1 @@ +uid://bxm6i0w8qei28 diff --git a/test/gut/test_player_lifecycle.gd b/test/gut/test_player_lifecycle.gd index 4310a84b..fff8dc3e 100644 --- a/test/gut/test_player_lifecycle.gd +++ b/test/gut/test_player_lifecycle.gd @@ -15,18 +15,19 @@ func before_each() -> void: original_settings = Globals.settings Globals.settings = GameSettingsResource.new() -## Per-test cleanup: Restore global state. +## Per-test cleanup: Restore global state and aggressively free the scene. ## :rtype: void func after_each() -> void: Globals.settings = original_settings - # NEW: Reverted to a hard free(). - # queue_free() delays deletion, causing GUT to falsely report the entire scene as orphans. - # A hard free() executes instantly, ensuring 0 lingering scene nodes when GUT checks memory. + # Use a hard free() instead of queue_free(). + # This instantly incinerates the scene, ensuring 0 lingering orphan nodes + # without needing to artificially pad the test time with frame flushes. if is_instance_valid(main_scene): main_scene.free() -## test_exit_tree_disconnects_signals | Lifecycle | Verify clean signal severing +## test_exit_tree_disconnects_signals | +## Lifecycle | Verify clean signal severing without breaking the SceneTree ## :rtype: void func test_exit_tree_disconnects_signals() -> void: gut.p("Testing: Player _exit_tree properly disconnects global signals.") @@ -45,20 +46,26 @@ func test_exit_tree_disconnects_signals() -> void: "fuel_depleted must be connected after player enters the tree." ) - # NEW: 2. Instead of remove_child() (which breaks the tree), manually call the lifecycle function + # 2. CRITICAL FIX: Manually trigger the lifecycle function instead of using remove_child(). player_root._exit_tree() # 3. Assert the signals were cleanly severed assert_false( Globals.settings.setting_changed.is_connected(player_root._on_setting_changed), - "setting_changed must be completely disconnected after player leaves the tree." + "setting_changed must be completely disconnected after _exit_tree is called." ) assert_false( Globals.settings.fuel_depleted.is_connected(player_root._on_player_out_of_fuel), - "fuel_depleted must be completely disconnected after player leaves the tree." + "fuel_depleted must be completely disconnected after _exit_tree is called." ) + + # 4. CRITICAL FIX: Flush the frame before GUT checks for orphans. + # This allows the queue_free() calls triggered during MainScene._ready() + # to finish sweeping the detached parallax sprites. + await get_tree().process_frame -## test_exit_tree_safe_without_globals | Safety | Verify no crashes on early exit +## test_exit_tree_safe_without_globals | +## Safety | Verify no crashes on early exit ## :rtype: void func test_exit_tree_safe_without_globals() -> void: gut.p("Testing: Player _exit_tree does not crash if Globals.settings is null.") @@ -69,6 +76,7 @@ func test_exit_tree_safe_without_globals() -> void: main_scene = load("res://scenes/main_scene.tscn").instantiate() player_root = main_scene.get_node("Player") + # Safely call _exit_tree in isolation player_root._exit_tree() assert_true(true, "_exit_tree handled a null Globals.settings state gracefully.")