-
-
Notifications
You must be signed in to change notification settings - Fork 1
Fix main scene orphan leaks and tune parallax decor with tests #550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ikostan
merged 32 commits into
main
from
orphan-node-leak-from-placeholder-sprites-in-main_scenegd
Apr 16, 2026
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
8a26313
[BUG] Orphan Node Leak from Placeholder Sprites in main_scene.gd #540
ikostan faa58f0
[QA] Orphan Node Leak Fix – Test Plan #549
ikostan 4782dba
Update test/gut/test_main_scene_orphan_nodes.gd
ikostan 49ac64a
Update main_scene.gd
ikostan 7bfa831
Add orphan leak helper and update tests
ikostan 1bfd05f
Update main_scene.gd
ikostan 2a647aa
suggestion: Consider tightening the type of stats_panel instead of us…
ikostan 3713757
Update test_main_scene_orphan_nodes.gd
ikostan 437369f
Update test_player_lifecycle.gd
ikostan c36a488
Update main_scene.gd
ikostan 5822274
Update test_player_lifecycle.gd
ikostan 608020e
[FEATURE] Randomize rotation for organic decor sprites in main scene …
ikostan 8eb7436
Update main_scene.gd
ikostan 237b33b
Make the background look as random and organic as possible
ikostan 4d999f4
Update main_scene.gd
ikostan 95b2b9e
[FEATURE] Increase parallax background chunk size to prevent visual r…
ikostan d63252e
Add GUT tests for parallax chunk sizes
ikostan 26f42a0
GUT test file dedicated specifically to verifying the randomized tran…
ikostan f38072c
Adjust bushes/decor layer height and density
ikostan cb04302
assertions that lock in the "Goldilocks Zone"
ikostan f7df782
Update test_main_scene_parallax_chunks.gd
ikostan 221cbd6
Update scripts/main_scene.gd
ikostan 60e24c2
Update main_scene.gd
ikostan 909d4a1
Merge branch 'orphan-node-leak-from-placeholder-sprites-in-main_scene…
ikostan aaddd37
Update test_decor_layer_transformations.gd
ikostan 7127f19
Rename performance test & add parallax checks
ikostan 3740a4c
Improve test teardown and prevent orphan nodes
ikostan deb1ae2
Performance assertion is likely to be CI-flaky, and _process side eff…
ikostan 2588460
suggestion: The hardcoded screens_tall = 8.0 is duplicated and would …
ikostan 2f94791
Update gut_test_helper.gd
ikostan a1c6821
Update test_main_scene_parallax_and_performance.gd
ikostan b9cfc17
Use GutHelper.safe_hard_free in tests
ikostan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| uid://dn1cexpx24og0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| uid://c63067f3sp0l4 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.