diff --git a/files/docs/milestones/21/Part_3_Centralize_UI_SFX_mapping_and_tighten_global_input_audio_guards.md b/files/docs/milestones/21/Part_3_Centralize_UI_SFX_mapping_and_tighten_global_input_audio_guards.md
new file mode 100644
index 000000000..c34ad8cfe
--- /dev/null
+++ b/files/docs/milestones/21/Part_3_Centralize_UI_SFX_mapping_and_tighten_global_input_audio_guards.md
@@ -0,0 +1,104 @@
+# Centralize UI SFX mapping and tighten global input audio guards
+
+
+## Summary by Sourcery
+
+Refine global UI input handling and audio routing to support device tracking, menu context guards, and data-driven SFX lookup, while expanding automated coverage around audio buses, mute controls, sliders, and button hooks.
+
+### Overall PR Context
+
+The PR focuses on refactoring global UI audio handling for better consistency, robustness, and test coverage in a Godot project. AI tools significantly aided in summarizing changes, spotting potential issues (e.g., flakiness, code complexity), and ensuring comprehensive test expansion. The final output benefited from iterative human refinements addressing bot feedback.
+
+Enhancements:
+
+- Introduce data-driven UI sound routing using logical action-to-SFX mappings and an asset map that decouples identifiers from file extensions and file layout.
+- Improve global input processing with device tracking, menu-context detection helpers, echo and mouse-motion filtering, and targeted ui_accept/ui_cancel navigation guards.
+- Adjust test infrastructure to avoid runner path brittleness, ensure bus availability in headless environments, and prevent cross-suite global state leakage.
+
+Tests:
+
+- Add extensive GUT suites for audio integration, volume hierarchy and slider focus behavior, global button hook registration, global input guards, and SFX asset resolution and input performance.
+- Update existing audio SFX centralization and navigation/escape SFX tests to align with the new logical identifier mapping, input guards, and test runner discovery patterns.
+- Expanded GUT coverage for audio focus gating, mute timing, hierarchy behavior, and input guardrails.
+
+New Features:
+
+- UI sounds are now driven by a centralized UI SFX mapping for consistent action audio.
+- Sound playback now supports logical sound identifiers resolved via an asset map.
+
+Bug Fixes:
+
+- UI navigation/cancel/accept sounds are more tightly gated by GUI focus and control type.
+- Mouse motion is ignored for UI sound triggering.
+- Audio mute/unmute and slider focus/drag behavior are stabilized, preserving expected click feedback and hardware mute state.
+
+Chores:
+
+- Removed the unit-test runner script.
+
+### Key Technical Decisions
+
+- **Data-driven SFX routing** over hardcoded strings → easier asset management and future expansion.
+- **Early-exit guards** in `Globals._input()` for performance and clarity.
+- **Extensive GUT integration tests** rather than unit tests only → better coverage of AudioServer + UI focus interactions.
+- Removal of `run_unit_tests.sh` → consolidation toward editor/GUT runner.
+
+---
+
+## Reviewer's Guide
+
+Strengthens global UI input-to-audio routing by centralizing UI SFX mappings, refining Globals._input guards, and expanding GUT integration tests for audio mute hierarchy, button hooks, sliders, and ui_accept/ui_cancel behavior.
+
+### File-Level Changes
+
+| Change | Details | Files |
+|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Refactor global input handling to gate menu contexts, ignore non-action/mouse motion events, and route UI navigation/accept/cancel sounds through data-driven helpers. |
- Add echo-event guard and hardware device tracking at the top of Globals._input.
- Introduce _check_menu_context to consolidate menu/scene/group/meta-based context detection with debug/CI fallbacks.
- Split navigation/cancel handling into _process_ui_navigation_sfx plus _handle_ui_cancel_action and _handle_ui_navigation_action, including ui_accept bypasses for BaseButton, Slider, LineEdit, and TextEdit.
- Ensure global button pressed handler routes through the shared UI SFX bus for consistent mute behavior.
| `scripts/core/globals.gd` |
+| Centralize UI SFX logical mappings and asset filename resolution, and update AudioManager to resolve logical IDs through an asset map with .wav fallback. | - Add UI_SFX mapping from input actions to logical SFX identifiers.
- Add SFX_ASSET_MAP mapping logical IDs to concrete filenames and extensions.
- Change AudioManager.play_sfx to treat sfx_name as a logical ID, resolve via SFX_ASSET_MAP, and append .wav for unmapped identifiers.
| `scripts/resources/audio_constants.gd`
`scripts/managers/audio_manager.gd` |
+| Extend and harden GUT audio SFX centralization, navigation, and quit-dialog tests to align with the new mapping and input guards. | - Reset Globals.options_open and stop pooled players in audio SFX centralization teardown to avoid global-state pollution.
- Add tests verifying asset-map extension resolution and unmapped .wav fallback for play_sfx, plus ignoring mouse motion in Globals._input.
- Switch nav/escape and quit-dialog suites to GutTest inheritance and fix assertion/message robustness and scene loading requirements.
| `test/gut/test_audio_sfx_centralization.gd`
`test/gut/test_nav_escape_sfx.gd`
`test/gut/test_quit_game_confirm_dialog_sfx.gd` |
+| Add integration suites covering audio bus mute propagation, hierarchy interactions, slider focus/drag resilience, global button hook policies, and global input ui_accept/navigation guards. | - Introduce test_audio_integration.gd to validate SFX bus mute/unmute routing, AudioServer bus bootstrap in headless environments, navigation focus safeguards, and mute timing behavior.
- Add test_audio_hierarchy_and_sliders.gd to verify master/SFX hierarchy locking/unlocking, independent bus states, and VolumeSlider drag state behavior under window focus loss.
- Add test_globals_button_hooks.gd to assert global button hook inclusion/exclusion rules, reparenting and duplicate scan idempotency, and cleanup after node destruction.
- Add test_globals_input_guards.gd to ensure ui_accept audio is suppressed for interactive controls (CheckButton, sliders, BaseButton/TextureButton) while still playing for passive controls and stale-focus navigation in menu contexts.
- Include associated .uid files for new GUT suites.
| `test/gut/test_audio_integration.gd`
`test/gut/test_audio_hierarchy_and_sliders.gd`
`test/gut/test_globals_button_hooks.gd`
`test/gut/test_globals_input_guards.gd`
`test/gut/test_audio_hierarchy_and_sliders.gd.uid`
`test/gut/test_audio_integration.gd.uid`
`test/gut/test_globals_button_hooks.gd.uid`
`test/gut/test_globals_input_guards.gd.uid` |
+| Stabilize key-mapping integration tests to avoid global input-device state leakage and ensure clean InputMap configuration per test. | - Cache and restore Globals.current_input_device across the key-mapping suite.
- Reset Globals.current_input_device to keyboard and clear/re-add Settings.ACTIONS in InputMap in before_each.
- Maintain backup/restore of user settings config and clean up menu instances after each test.
| `test/gut/test_integration_key_mapping.gd` |
+| Add documentation for the milestone and remove obsolete unit-test runner tooling. | - Create milestone document describing centralized UI SFX mapping, tightened input audio guards, and expanded audio-related tests.
- Delete workspace/run_unit_tests.sh to retire the legacy unit-test runner script.
| `files/docs/milestones/21/Part_3_Centralize_UI_SFX_mapping_and_tighten_global_input_audio_guards.md`
`workspace/run_unit_tests.sh` |
+
+### Assessment against linked issues
+
+| Issue | Objective | Addressed | Explanation |
+|------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------|-------------|
+| https://github.com/ikostan/SkyLockAssault/issues/493 | Add a GUT integration test suite at res://test/gut/test_audio_integration.gd that verifies muted SFX bus propagation from AudioManager through UI button interaction to Godot's AudioServer, following the described Scenario A behavior and architectural nuances. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/493 | Add a GUT integration test in the same suite that verifies unmuted SFX bus behavior for UI button interaction and AudioServer state (Scenario B), confirming audible playback and correct hardware mute flags. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/493 | Configure the audio integration test suite with the specified lifecycle hooks and architectural requirements (placement under test/gut, using AudioManager.apply_volume_to_bus, targeting the parent SFX bus index, and sanitizing the AudioManager pool via cleanup_for_test). | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/764 | Implement correct UI audio behavior for navigation, accept, and cancel actions, including menu-context gating, echo mitigation, suppression for LineEdit/TextEdit and Slider controls, and protection against double audio on buttons/flat buttons/dialog internals. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/764 | Provide automated GUT test coverage (including test_nav_escape_sfx.gd and test_quit_game_confirm_dialog_sfx.gd and related new suites) to verify the specified UI audio behaviors and acceptance criteria. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/785 | Prevent global ui_accept audio from playing when toggling audio bus Mute CheckButtons via keyboard/gamepad, so only the local 'check' sound effect is heard. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/786 | Update global UI input handling so that pressing ui_accept (Enter/Space) while a volume slider is focused does not play the generic ui_accept sound, leaving audio feedback only to slider horizontal adjustments. | ✅ | |
+| https://github.com/ikostan/SkyLockAssault/issues/786 | Add automated tests that verify ui_accept audio is suppressed for focused volume sliders (and similar controls) to prevent regressions of the unwanted sound. | ✅ | |
+
+### Possibly linked issues
+
+- **#N/A**: PR creates test_audio_integration.gd with Scenario A/B mute propagation tests and proper setup/teardown exactly as the issue requests.
+- **#FEATURE UI Audio Logic Unit Tests (GUT)**: PR implements the requested audio_settings UI-to-AudioManager GUT suites (mute, sliders, focus) plus extra global audio features.
+- **#[FEATURE] UI Audio Logic Unit Tests (GUT)**: PR implements the requested nav/escape and quit dialog GUT tests and supporting Globals/AudioManager audio routing logic.
+
+---
+
+**Bots/AI Contributors to PR #784**
+
+### AI/Bot-Assisted Contributions
+
+These automated tools provided code summaries, reviews, suggestions, and analysis that influenced the development and refinement of the PR:
+
+- **@sourcery-ai**: Generated the primary PR summary, reviewer's guide, and multiple code reviews with high-level feedback on focus gating, test brittleness, scene dependencies, timing in tests, and metadata files. Offered actionable prompts for addressing comments.
+- **@coderabbitai**: Provided a detailed summary of new features, bug fixes, and tests. Posted actionable inline comments (e.g., on test flakiness in `test_audio_integration.gd` and `_input` function complexity/return limits). Included context from prior learnings in the repo.
+- **@deepsource-io**: Performed a code review across changes (e.g., commits in the range 939000c...912c858), generating a PR report card with assessments on security, reliability, complexity, and hygiene. Included Python/JavaScript analysis links and inline issue summaries.
+
+No evidence of other common bots like @dependabot in the visible PR activity, commits, or reviews.
+
+These tools did not author code but provided valuable guardrails: Sourcery and CodeRabbit drove iterative refinements (especially around test robustness and input guard logic), while DeepSource contributed static analysis.
+
+### Human Contributors
+
+- **@ikostan**: Primary author who implemented the core changes—centralizing UI SFX mappings in `AudioConstants`, tightening global input guards in `Globals._input()` (device tracking, menu context, echo/mouse filters, focus-based navigation/accept/cancel logic), updating `AudioManager`, and adding/extending extensive GUT integration tests for audio buses, mute hierarchy, sliders, button hooks, and input guards. Also handled test infrastructure cleanup (e.g., removing `run_unit_tests.sh`).
+- **@espanakosta-jpg**: No visible direct commits, reviews, or comments attributed in the PR timeline or files (based on available data).
+
+---
+
diff --git a/scenes/volume_controls/sfx_menu_volume_control.tscn b/scenes/volume_controls/sfx_menu_volume_control.tscn
index b776c74b0..994e52301 100644
--- a/scenes/volume_controls/sfx_menu_volume_control.tscn
+++ b/scenes/volume_controls/sfx_menu_volume_control.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=4 format=3 uid="uid://cm2nruujxhdhu"]
+[gd_scene format=3 uid="uid://cm2nruujxhdhu"]
[ext_resource type="FontFile" uid="uid://borwvgqdgawbj" path="res://files/fonts/EMPIREST.TTF" id="1_f34wo"]
[ext_resource type="Script" uid="uid://8hd1kvmb5bt1" path="res://scripts/ui/components/volume_slider.gd" id="2_3a0os"]
@@ -10,7 +10,7 @@ corner_radius_bottom_right = 20
corner_radius_bottom_left = 20
shadow_size = 5
-[node name="SFXMenu" type="Control"]
+[node name="SFXMenu" type="Control" unique_id=1615954462]
custom_minimum_size = Vector2(385, 24)
layout_mode = 3
anchors_preset = 0
@@ -20,7 +20,7 @@ size_flags_horizontal = 3
size_flags_vertical = 3
tooltip_text = "SFX Menu Volume Control"
-[node name="SFXMenuLabel" type="Label" parent="."]
+[node name="SFXMenuLabel" type="Label" parent="." unique_id=2003122289]
layout_mode = 0
offset_top = 3.0
offset_right = 100.0
@@ -30,7 +30,7 @@ theme_override_fonts/font = ExtResource("1_f34wo")
theme_override_font_sizes/font_size = 18
text = "{MENU}"
-[node name="HSlider" type="HSlider" parent="."]
+[node name="HSlider" type="HSlider" parent="." unique_id=1622043815]
layout_direction = 2
layout_mode = 0
offset_left = 125.0
@@ -48,7 +48,7 @@ ticks_on_borders = true
script = ExtResource("2_3a0os")
bus_name = "SFX_Menu"
-[node name="Mute" type="CheckButton" parent="."]
+[node name="Mute" type="CheckButton" parent="." unique_id=1618523756]
layout_direction = 1
layout_mode = 0
offset_left = 360.0
diff --git a/scripts/core/globals.gd b/scripts/core/globals.gd
index a06b3ea21..314f3dd8c 100644
--- a/scripts/core/globals.gd
+++ b/scripts/core/globals.gd
@@ -13,7 +13,7 @@ const UI_NAV_SOUND_PATH: String = "res://files/sounds/sfx/ui_navigation.wav"
# --- TASK #529: Encryption Key Management ---
## Centralized key for securing local configuration files.
-## This ensures consistent encryption/decryption across different game systems. [cite: 3]
+## This ensures consistent encryption/decryption across different game systems.
## Define the variable by pulling from ProjectSettings.
## If the setting doesn't exist, it falls back to a non-secure string.
var save_encryption_pass: String = _get_encryption_key()
@@ -383,61 +383,129 @@ static func set_game_version_for_tests(value: String) -> void:
## Use _input instead of _unhandled_input to catch events BEFORE the UI consumes them.
func _input(event: InputEvent) -> void:
- # The Ultimate Menu Check: Does a UI element currently have keyboard/gamepad focus?
+ # Gate 1: Echo Input Mitigation
+ if event.is_echo():
+ return
+
+ # Gate 2: Comprehensive Hardware Device Tracking
+ _track_input_device(event)
+
var focus_owner: Control = get_viewport().gui_get_focus_owner()
var ui_has_focus: bool = is_instance_valid(focus_owner)
- # Gate 1: Only play UI sounds if a UI element is focused OR we are in a known menu state
+ # Gate 3: Broadened Menu Layer Context Safeguards
+ if not _check_menu_context(ui_has_focus):
+ return
+
+ # Gate 4: Drop heavy frame-rate mouse wiggles or non-action events early
+ if event is InputEventMouseMotion or not event.is_action_type():
+ return
+
+ # Gate 5: Process Menu UI Sound Effects via Data-Driven Lookup
+ _process_ui_navigation_sfx(event, focus_owner, ui_has_focus)
+
+
+## Private helper to dynamically track the active hardware user control scheme.
+func _track_input_device(event: InputEvent) -> void:
+ if event is InputEventKey or event is InputEventMouseButton or event is InputEventMouseMotion:
+ current_input_device = "keyboard"
+ elif event is InputEventJoypadButton or event is InputEventJoypadMotion:
+ current_input_device = "gamepad"
+
+
+## Evaluates tree states and explicit markers to safeguard input contexts.
+func _check_menu_context(ui_has_focus: bool) -> bool:
var is_menu_context: bool = (
get_tree().paused or options_open or not hidden_menus.is_empty() or ui_has_focus
)
- # Test helper fallback: support menu context detection via current_scene name for GUT tests
- # Gated strictly behind debug/ci features to prevent leakage into production shipped builds.
- if (
- (OS.has_feature("debug") or OS.has_feature("ci"))
- and not is_menu_context
- and get_tree().current_scene
- and "Menu" in get_tree().current_scene.name
- ):
- is_menu_context = true
+ # Cache the current scene reference once to guarantee mid-frame evaluation consistency
+ var active_scene: Node = get_tree().current_scene if get_tree() else null
+ if not is_instance_valid(active_scene):
+ return is_menu_context
+ # Explicit marker evaluation with a substring fallback for legacy compliance
if not is_menu_context:
- return
+ if (
+ active_scene.is_in_group("menu_context")
+ or active_scene.has_meta("is_menu_context")
+ or "Menu" in active_scene.name
+ ):
+ is_menu_context = true
- # ADDED: Sound selection effect on hitting ESC/ui_cancel within any valid menu context
- if event.is_action_pressed("ui_cancel", false):
- # Bypass triggers when value-editing, toggle, or selection controls have active focus
+ # Test helper fallback: support menu context detection during automated test suite runs
+ if (OS.has_feature("debug") or OS.has_feature("ci")) and not is_menu_context:
if (
- focus_owner is LineEdit
- or focus_owner is TextEdit
- or focus_owner is Range
- or focus_owner is CheckButton
- or focus_owner is OptionButton
+ "Menu" in active_scene.name
+ or active_scene.has_meta("is_menu_context")
+ or active_scene.is_in_group("menu_context")
):
- return
+ is_menu_context = true
- # Secure bypass gate for custom InputRemapButton configurations
- if is_instance_valid(focus_owner) and focus_owner.get_script() != null:
- if (
- "action" in focus_owner
- or "action_name" in focus_owner
- or focus_owner.has_method("cancel_remap")
- ):
- return
+ return is_menu_context
- AudioManager.play_sfx("ui_cancel")
- return
- for action: String in _nav_actions:
- # FIXED: Changed from 'Input.is_action_just_pressed' to pass the automated verification
+## Matches actions against AudioConstants configurations to execute global menu sfx.
+func _process_ui_navigation_sfx(
+ event: InputEvent, focus_owner: Control, ui_has_focus: bool
+) -> void:
+ for action: String in AudioConstants.UI_SFX.keys():
if event.is_action_pressed(action, false):
- # Prevent double-audio when adjusting sliders.
- if focus_owner is Slider and (action == "ui_left" or action == "ui_right"):
+ # Context Guard A: Handle Escape/Cancellation Safeguards
+ if action == "ui_cancel":
+ _handle_ui_cancel_action(focus_owner, action)
return
- _play_ui_navigation_sfx()
- return # Exit once sound is triggered to avoid double-plays
+ # Context Guard C: Quietly drop events handling native element submissions
+ if action == "ui_accept":
+ if (
+ focus_owner is BaseButton
+ or focus_owner is Slider
+ or focus_owner is LineEdit
+ or focus_owner is TextEdit
+ ):
+ return
+
+ # Context Guard B: Handle Directional & Focus Navigation Safeguards
+ _handle_ui_navigation_action(action, focus_owner, ui_has_focus)
+ return
+
+
+## Separated execution logic for UI cancellation actions.
+func _handle_ui_cancel_action(focus_owner: Control, action: String) -> void:
+ var is_editing_control: bool = (
+ focus_owner is LineEdit
+ or focus_owner is TextEdit
+ or focus_owner is Range
+ or focus_owner is CheckButton
+ or focus_owner is OptionButton
+ )
+
+ # Upgraded remap detection to avoid brittle 'in' operator checks on Node instances
+ var is_remap_control: bool = false
+ if is_instance_valid(focus_owner):
+ is_remap_control = (
+ focus_owner.has_method("cancel_remap")
+ or focus_owner.get("action") != null
+ or focus_owner.get("action_name") != null
+ )
+
+ if not is_editing_control and not is_remap_control:
+ var logical_id: String = AudioConstants.UI_SFX[action]
+ AudioManager.play_sfx(logical_id, AudioConstants.BUS_SFX)
+
+
+## Separated execution logic for directional UI focus swaps.
+func _handle_ui_navigation_action(action: String, focus_owner: Control, ui_has_focus: bool) -> void:
+ var is_horizontal_slider: bool = (
+ focus_owner is Slider and (action == "ui_left" or action == "ui_right")
+ )
+ var is_nav_action: bool = action in _nav_actions
+ var should_play_nav_sfx: bool = (ui_has_focus or is_nav_action) and not is_horizontal_slider
+
+ if should_play_nav_sfx:
+ var logical_id: String = AudioConstants.UI_SFX[action]
+ AudioManager.play_sfx(logical_id, AudioConstants.BUS_SFX)
## Internal helper to play the navigation sound through the dedicated Menu SFX bus.
@@ -643,4 +711,6 @@ func _on_node_added(node: Node) -> void:
## Centralized button audio execution target to prevent lambda churn
func _on_global_button_pressed() -> void:
- AudioManager.play_sfx("ui_accept")
+ # Explicitly route button accepts through the shared UI SFX bus to guarantee consistent
+ # muting behavior
+ AudioManager.play_sfx("ui_accept", AudioConstants.BUS_SFX)
diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd
index 2fa8b98b5..f2c31cc30 100644
--- a/scripts/managers/audio_manager.gd
+++ b/scripts/managers/audio_manager.gd
@@ -369,7 +369,7 @@ func reset_volumes() -> void:
## Centralized SFX Playback API (Issue #570)
## Handles non-positional audio with LRU caching and auto-cleanup.
-## :param sfx_name: The filename without extension (e.g., "slider").
+## :param sfx_name: The logical identifier (e.g., "ui_navigation" or "slider").
## :param bus_name: Target audio bus (defaults to SFX_Menu).
## :param pitch_scale: Pitch override for variety.
## :param volume_db: Volume offset in decibels.
@@ -388,7 +388,15 @@ func play_sfx(
# 1. Resolve and Cache the AudioStream (with LRU Eviction)
if not _sfx_cache.has(sfx_name):
- var full_path: String = SFX_DIR_PATH + sfx_name + ".wav"
+ # Resolve the logical name to its exact mapped file name
+ var file_name: String = sfx_name
+ if sfx_name in AudioConstants.SFX_ASSET_MAP:
+ file_name = AudioConstants.SFX_ASSET_MAP[sfx_name]
+ else:
+ # Structural fallback safely preserving legacy/direct calls
+ file_name += ".wav"
+
+ var full_path: String = SFX_DIR_PATH + file_name
# Safety guard against non-existent files to block core engine loader errors from polluting tests
if not ResourceLoader.exists(full_path):
diff --git a/scripts/resources/audio_constants.gd b/scripts/resources/audio_constants.gd
index c8f7883d6..e2e65fcfd 100644
--- a/scripts/resources/audio_constants.gd
+++ b/scripts/resources/audio_constants.gd
@@ -65,3 +65,29 @@ const BUS_CONFIG: Dictionary = {
"default_muted": false
}
}
+
+# --- Global UI SFX Mappings (Issue #490 Compliance) ---
+# Decoupled from file extensions and folder layouts to prevent silent breaks on asset moves
+const UI_SFX: Dictionary = {
+ "ui_up": "ui_navigation",
+ "ui_down": "ui_navigation",
+ "ui_left": "ui_navigation",
+ "ui_right": "ui_navigation",
+ "ui_focus_next": "ui_navigation",
+ "ui_focus_prev": "ui_navigation",
+ "ui_accept": "ui_accept",
+ "ui_cancel": "ui_cancel"
+}
+
+# --- SFX Asset Path Resolution Map ---
+# Maps logical SFX identifiers to their exact filenames with extensions.
+# This prevents asset changes from requiring script alterations.
+const SFX_ASSET_MAP: Dictionary = {
+ "slider": "slider.wav",
+ "mute_toggle": "check.wav",
+ "ui_navigation": "ui_navigation.wav",
+ "ui_accept": "ui_accept.wav",
+ "ui_cancel": "ui_cancel.wav",
+ "airplane_prop": "airplane_prop.ogg",
+ "retro_laser": "retro-laser-1-236669.mp3"
+}
diff --git a/test/gut/test_audio_hierarchy_and_sliders.gd b/test/gut/test_audio_hierarchy_and_sliders.gd
new file mode 100644
index 000000000..e2875bc50
--- /dev/null
+++ b/test/gut/test_audio_hierarchy_and_sliders.gd
@@ -0,0 +1,203 @@
+## Copyright (C) 2026 Egor Kostan
+## SPDX-License-Identifier: GPL-3.0-or-later
+## test_audio_hierarchy_and_sliders.gd
+##
+## Integration suite verifying multi-tiered UI interactivity locks and volume
+## component tracking security during external focus loss conditions.
+extends "res://addons/gut/test.gd"
+
+const SIGNAL_SETTLE_FRAMES: int = 2
+
+var audio_scene: PackedScene = load(GamePaths.AUDIO_SETTINGS_SCENE)
+var audio_instance: Control
+
+
+func before_each() -> void:
+ AudioManager.reset_volumes()
+ audio_instance = audio_scene.instantiate() as Control
+ add_child_autofree(audio_instance)
+ await settle_ui()
+
+
+func after_each() -> void:
+ if is_instance_valid(audio_instance):
+ audio_instance.queue_free()
+ audio_instance = null
+ await settle_ui()
+
+
+## Shared helper to wait for deferred signals and UI layouts to settle completely.
+func settle_ui() -> void:
+ await wait_process_frames(SIGNAL_SETTLE_FRAMES)
+
+
+## Shared helper to cleanly synthesize an active left-click mouse drag interaction sequence.
+func _begin_drag(slider: VolumeSlider) -> void:
+ slider.grab_focus()
+ var mouse_event := InputEventMouseButton.new()
+ mouse_event.button_index = MOUSE_BUTTON_LEFT
+ mouse_event.pressed = true
+ slider._on_gui_input(mouse_event)
+
+
+# ==========================================================================
+# 1. CORE HIERARCHY PROPAGATION
+# ==========================================================================
+
+## Verifies that muting Master volume actively propagates disabled flags
+## down to the entire downstream audio configuration panel.
+func test_master_mute_locks_entire_child_hierarchy() -> void:
+ # Arrange
+ # Clean baseline is handled by before_each()
+
+ # Act
+ AudioManager.set_muted(AudioConstants.BUS_MASTER, true)
+ await settle_ui()
+
+ # Assert
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_MASTER), "Model: Master must be logged as muted.")
+ assert_true(audio_instance.mute_music.disabled, "UI: Music Mute should lock out when Master is muted.")
+ assert_false(audio_instance.music_slider.editable, "UI: Music Slider should be uneditable when Master is muted.")
+ assert_true(audio_instance.mute_sfx.disabled, "UI: SFX Mute should lock out when Master is muted.")
+ assert_false(audio_instance.weapon_slider.editable, "UI: Weapon Sub-slider must freeze when Master hierarchy closes.")
+
+
+## Verifies that muting the parent SFX channel selectively locks sub-buses
+## while leaving unrelated tracks like Music operational.
+func test_sfx_mute_locks_only_sfx_sub_buses() -> void:
+ # Arrange
+ AudioManager.set_muted(AudioConstants.BUS_MASTER, false)
+ await wait_process_frames(1)
+
+ # Act
+ AudioManager.set_muted(AudioConstants.BUS_SFX, true)
+ await settle_ui()
+
+ # Assert
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_SFX), "Model: SFX must be logged as muted.")
+ assert_false(audio_instance.mute_music.disabled, "UI: Music selection toggle must remain open.")
+ assert_true(audio_instance.music_slider.editable, "UI: Music slider scale should remain operational.")
+
+ assert_true(audio_instance.mute_weapon.disabled, "UI: Weapon Mute button must lock down under parent SFX mute conditions.")
+ assert_false(audio_instance.weapon_slider.editable, "UI: Weapon volume adjustment track must lock down.")
+ assert_true(audio_instance.mute_rotor.disabled, "UI: Rotor structural toggle must lock down.")
+
+
+# ==========================================================================
+# 2. STATE RESTORATION AND TRANSITIONS (REGRESSION PROTECTION)
+# ==========================================================================
+
+## Verifies that unmuting the Master bus restores full interactivity across all child nodes.
+func test_master_unmute_restores_child_interactivity() -> void:
+ # Arrange: Force an initial locked state completely down the hierarchy
+ AudioManager.set_muted(AudioConstants.BUS_MASTER, true)
+ await settle_ui()
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_MASTER))
+ assert_true(audio_instance.mute_music.disabled, "Precondition: Hierarchy must be initially locked.")
+
+ # Act: Unmute master and allow signals to settle
+ AudioManager.set_muted(AudioConstants.BUS_MASTER, false)
+ await settle_ui()
+
+ # Assert: All standard primary children must become operational again alongside model states
+ assert_false(AudioManager.get_muted(AudioConstants.BUS_MASTER), "Model: Master must be logged as unmuted.")
+ assert_false(audio_instance.mute_music.disabled, "UI: Music Mute should be re-enabled when Master is unmuted.")
+ assert_true(audio_instance.music_slider.editable, "UI: Music Slider should be editable when Master is unmuted.")
+ assert_false(audio_instance.mute_sfx.disabled, "UI: SFX Mute should be re-enabled when Master is unmuted.")
+ assert_true(audio_instance.sfx_slider.editable, "UI: SFX Slider should be editable when Master is unmuted.")
+
+
+## Verifies that nested sub-buses correctly respond to intermediate parent state changes.
+func test_nested_hierarchy_transitions_re_enable_sub_buses() -> void:
+ # Arrange: Keep Master unmuted, but mute parent SFX bus to lock sub-buses down
+ AudioManager.set_muted(AudioConstants.BUS_MASTER, false)
+ AudioManager.set_muted(AudioConstants.BUS_SFX, true)
+ await settle_ui()
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_SFX))
+ assert_true(audio_instance.mute_weapon.disabled, "Precondition: Sub-buses must be locked down by parent SFX.")
+
+ # Act: Unmute the intermediate parent SFX bus
+ AudioManager.set_muted(AudioConstants.BUS_SFX, false)
+ await settle_ui()
+
+ # Assert: Sub-buses must dynamically follow the intermediate parent restoration
+ assert_false(AudioManager.get_muted(AudioConstants.BUS_SFX), "Model: SFX parent must be logged as unmuted.")
+ assert_false(audio_instance.mute_weapon.disabled, "UI: Weapon Mute should re-enable when SFX is unmuted.")
+ assert_true(audio_instance.weapon_slider.editable, "UI: Weapon Slider should become editable when SFX is unmuted.")
+ assert_false(audio_instance.mute_rotor.disabled, "UI: Rotor Mute should re-enable when SFX is unmuted.")
+ assert_true(audio_instance.rotor_slider.editable, "UI: Rotor Slider should become editable when SFX is unmuted.")
+
+
+## Verifies that changing a sub-bus state does not leak or contaminate unrelated peer controls.
+func test_unrelated_bus_mutations_preserve_independent_states() -> void:
+ # Arrange: Mute Music explicitly to create a distinct custom state snapshot
+ AudioManager.set_muted(AudioConstants.BUS_MUSIC, true)
+ await settle_ui()
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_MUSIC))
+ assert_false(audio_instance.mute_music.button_pressed, "Precondition: Music UI should show muted (not pressed).")
+
+ # Act: Perform high-frequency mutations on a completely separate SFX sub-branch
+ AudioManager.set_muted(AudioConstants.BUS_SFX_WEAPON, true)
+ await wait_process_frames(1)
+ AudioManager.set_muted(AudioConstants.BUS_SFX_WEAPON, false)
+ await settle_ui()
+
+ # Assert: The isolated music configuration controls and models must remain completely unchanged
+ assert_true(AudioManager.get_muted(AudioConstants.BUS_MUSIC), "Model: Music must firmly retain its muted state.")
+ assert_false(audio_instance.mute_music.button_pressed, "UI: Music control state must survive unrelated weapon sub-bus changes.")
+ assert_false(audio_instance.music_slider.editable, "UI: Music slider must remain frozen via its own specific mute rule.")
+
+
+# ==========================================================================
+# 3. SLIDER TRACKING & FOCUS RESILIENCE
+# ==========================================================================
+
+## Verifies that dragging tracking states fail-safe instantly if an external event
+## steals application layout window alignment.
+func test_slider_drag_state_drops_on_application_focus_loss() -> void:
+ # Arrange
+ var slider: VolumeSlider = audio_instance.master_slider
+ _begin_drag(slider)
+ assert_true(slider.is_user_dragging(), "Precondition: Slider must actively confirm dragging status profile.")
+
+ # Act
+ # Direct notification invocation is intentional because GUT cannot synthesize OS window focus changes.
+ slider._notification(Control.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
+
+ # Assert
+ assert_false(
+ slider.is_user_dragging(),
+ "Slider must instantly drop active drag state tracking when OS window focus drops."
+ )
+
+
+## Verifies that executing multiple unexpected focus loss calls back-to-back behaves safely.
+func test_slider_focus_loss_notification_is_idempotent() -> void:
+ # Arrange
+ var slider: VolumeSlider = audio_instance.master_slider
+ _begin_drag(slider)
+ assert_true(slider.is_user_dragging(), "Precondition: Slider tracking drag sequence.")
+
+ # Act
+ # Direct notification invocation is intentional because GUT cannot synthesize OS window focus changes.
+ slider._notification(Control.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
+ slider._notification(Control.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
+ await wait_process_frames(1)
+
+ # Assert
+ assert_false(slider.is_user_dragging(), "Slider drag state must remain safely dropped.")
+
+
+## Verifies that an app focus loss notification does not cause an accidental toggle inversion if the user wasn't dragging.
+func test_slider_focus_loss_does_not_toggle_drag_state_unintentionally() -> void:
+ # Arrange
+ var slider: VolumeSlider = audio_instance.master_slider
+ assert_false(slider.is_user_dragging(), "Precondition: Slider is stationary.")
+
+ # Act
+ # Direct notification invocation is intentional because GUT cannot synthesize OS window focus changes.
+ slider._notification(Control.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
+ await wait_process_frames(1)
+
+ # Assert
+ assert_false(slider.is_user_dragging(), "Slider drag state must strictly remain false; no accidental inversion.")
diff --git a/test/gut/test_audio_hierarchy_and_sliders.gd.uid b/test/gut/test_audio_hierarchy_and_sliders.gd.uid
new file mode 100644
index 000000000..4c72c3049
--- /dev/null
+++ b/test/gut/test_audio_hierarchy_and_sliders.gd.uid
@@ -0,0 +1 @@
+uid://br6x7t2dkojnk
diff --git a/test/gut/test_audio_integration.gd b/test/gut/test_audio_integration.gd
new file mode 100644
index 000000000..0bba345fb
--- /dev/null
+++ b/test/gut/test_audio_integration.gd
@@ -0,0 +1,221 @@
+## Copyright (C) 2026 Egor Kostan
+## SPDX-License-Identifier: GPL-3.0-or-later
+## test_audio_integration.gd
+##
+## Comprehensive GUT integration test suite verified against headless runner environments.
+extends GutTest
+
+const TARGET_BUS: String = AudioConstants.BUS_SFX
+const MENU_BUS: String = AudioConstants.BUS_SFX_MENU
+
+var bus_idx: int = -1
+
+
+func before_all() -> void:
+ # FAIL-FAST / BOOTSTRAP: Ensure the test runner environment possesses the required audio buses.
+ # This guarantees the test suite passes flawlessly on stripped headless CI/CD systems.
+ var required_buses: Array[String] = [
+ AudioConstants.BUS_MASTER,
+ AudioConstants.BUS_MUSIC,
+ TARGET_BUS,
+ MENU_BUS
+ ]
+
+ for bus_name in required_buses:
+ var idx: int = AudioServer.get_bus_index(bus_name)
+ if idx == -1:
+ # Automatically bootstrap missing environments at runtime
+ AudioServer.add_bus()
+ var new_idx: int = AudioServer.get_bus_count() - 1
+ AudioServer.set_bus_name(new_idx, bus_name)
+
+
+func before_each() -> void:
+ AudioManager.reset_volumes()
+
+ # Cache and validate our bus index before running any assertions
+ bus_idx = AudioServer.get_bus_index(TARGET_BUS)
+ assert_ne(bus_idx, -1, "FAIL-FAST: Target audio bus '%s' could not be resolved by the engine." % TARGET_BUS)
+
+
+func after_each() -> void:
+ AudioManager.stop_all_sfx()
+ if AudioManager.has_method("cleanup_for_test"):
+ AudioManager.cleanup_for_test()
+ AudioManager.reset_volumes()
+
+ # FIX: Prevent global state pollution if Scenario C assertions fail early
+ Globals.options_open = false
+
+
+## Scenario A: Verify that when the parent SFX bus is muted, UI button presses
+## still allocate streams in the pool, but the native hardware bus remains muted.
+func test_sfx_mute_silences_ui_accept_at_audioserver_level() -> void:
+ watch_signals(AudioManager)
+
+ AudioManager.set_muted(TARGET_BUS, true)
+ AudioManager.apply_volume_to_bus(TARGET_BUS, AudioManager.get_volume(TARGET_BUS), true)
+
+ assert_signal_emitted_with_parameters(AudioManager, "mute_toggled", [TARGET_BUS, true])
+
+ var dummy_btn := Button.new()
+ add_child_autofree(dummy_btn)
+ await wait_process_frames(1)
+
+ dummy_btn.pressed.emit()
+ await wait_process_frames(1)
+
+ assert_true(AudioManager.get_muted(TARGET_BUS), "Logical: Manager must track bus as muted.")
+ assert_true(AudioServer.is_bus_mute(bus_idx), "Hardware: Native AudioServer bus must be muted.")
+ assert_true(AudioManager.is_any_sfx_playing(), "Pool should successfully allocate stream despite hardware mute.")
+ assert_eq(AudioManager.get_active_sfx_playback_count(), 1, "Exactly one AudioStreamPlayer should be active.")
+
+
+## Scenario B: Verify that unmuted UI interactions audibly play and leave the hardware server unmuted.
+func test_sfx_unmuted_plays_audibly_at_audioserver_level() -> void:
+ AudioManager.set_muted(TARGET_BUS, false)
+ AudioManager.apply_volume_to_bus(TARGET_BUS, AudioManager.get_volume(TARGET_BUS), false)
+
+ var dummy_btn := Button.new()
+ add_child_autofree(dummy_btn)
+ await wait_process_frames(1)
+
+ dummy_btn.pressed.emit()
+ await wait_process_frames(1)
+
+ assert_false(AudioServer.is_bus_mute(bus_idx), "Hardware: Native AudioServer bus must be unmuted.")
+ assert_true(AudioManager.is_any_sfx_playing(), "Pool should actively play allocated stream.")
+
+
+## Scenario C: Verify navigation SFX routing, focus-gating, and stale focus safeguards.
+func test_ui_navigation_sfx_requires_gui_focus() -> void:
+ Globals.options_open = true
+
+ # FIX: Hard-clear the viewport focus to eliminate ambient test runner focus flakiness
+ var current_focus := get_viewport().gui_get_focus_owner()
+ if is_instance_valid(current_focus):
+ current_focus.release_focus()
+
+ # Assert pristine environment state as an explicit precondition
+ assert_null(get_viewport().gui_get_focus_owner(), "Precondition failed: Viewport focus must be completely empty.")
+
+ var unfocused_slider := HSlider.new()
+ add_child_autofree(unfocused_slider)
+ unfocused_slider.release_focus()
+ await wait_process_frames(1)
+
+ var nav_event := InputEventAction.new()
+ nav_event.action = "ui_down"
+ nav_event.pressed = true
+ Globals._input(nav_event)
+ await wait_process_frames(1)
+
+ # FIX VERIFIED: Because we added the stale focus safeguard to Globals._input,
+ # navigation audio is now INTENTIONALLY allowed to play in menu contexts even if the focus owner flag is stale.
+ assert_true(AudioManager.is_any_sfx_playing(), "Stale Focus Safeguard: Navigation audio should play in menu context even if focus is transiently empty.")
+
+ # Reset the playback state cleanly so we can isolate the active focus test scenario right below
+ AudioManager.stop_all_sfx()
+
+ unfocused_slider.grab_focus()
+ await wait_process_frames(1)
+ Globals._input(nav_event)
+ await wait_process_frames(1)
+
+ assert_true(AudioManager.is_any_sfx_playing(), "Navigation audio should trigger when GUI element has active focus.")
+ assert_true(AudioManager.get_active_sfx_stream_path().contains("ui_navigation"), "Active stream path should point to ui_navigation asset.")
+ # FIX: Removed Globals.options_open = false from here to avoid short-circuit skips
+
+
+## Scenario E: Verify that watch_signals correctly isolates test actions from setup pollution.
+func test_signal_isolation_from_setup_pollution() -> void:
+ AudioManager.set_muted(TARGET_BUS, false)
+ watch_signals(AudioManager)
+
+ AudioManager.set_muted(TARGET_BUS, true)
+
+ assert_eq(
+ get_signal_emit_count(AudioManager, "mute_toggled"),
+ 1,
+ "Watcher should strictly capture emissions that occurred after watch_signals() was invoked."
+ )
+ assert_signal_emitted_with_parameters(AudioManager, "mute_toggled", [TARGET_BUS, true])
+
+
+## Scenario F: Verify rapid mute toggling cancels pending hardware cutoffs.
+func test_rapid_mute_toggle_cancels_pending_hardware_cutoff() -> void:
+ AudioManager.set_muted(TARGET_BUS, false)
+ AudioManager.apply_volume_to_bus(TARGET_BUS, 1.0, false)
+
+ AudioManager.set_muted(TARGET_BUS, true)
+ await wait_seconds(0.05)
+
+ AudioManager.set_muted(TARGET_BUS, false)
+ await wait_seconds(0.15)
+
+ assert_false(AudioServer.is_bus_mute(bus_idx), "Hardware bus must remain unmuted; rapid updates abort pending cutoff timers.")
+
+
+## Scenario D: Verify hardware cutoff safety window.
+func test_ui_mute_toggle_defers_hardware_cutoff_for_click_feedback() -> void:
+ # 1. FAIL-FAST GUARD: Define the explicit path and verify its integrity on disk
+ const AUDIO_SETTINGS_PATH = "res://scenes/audio_settings.tscn"
+ assert_true(FileAccess.file_exists(AUDIO_SETTINGS_PATH), "FAIL-FAST: '%s' is missing from the project directory!" % AUDIO_SETTINGS_PATH)
+
+ var audio_settings_scene: = load(AUDIO_SETTINGS_PATH)
+ assert_not_null(audio_settings_scene, "FAIL-FAST: Failed to load audio_settings.tscn resource.")
+
+ var audio_menu: = audio_settings_scene.instantiate() as Control
+ assert_not_null(audio_menu, "FAIL-FAST: Instantiated audio settings root node is not a Control type.")
+ add_child_autofree(audio_menu)
+ await wait_process_frames(1)
+
+ # 2. BYPASS BRITTLE NODE PATHS: Extract the script variable directly via object reflection
+ var sfx_mute_btn: CheckButton = audio_menu.get("mute_sfx") as CheckButton
+ assert_not_null(sfx_mute_btn, "FAIL-FAST: The 'mute_sfx' variable could not be extracted from the audio settings script context.")
+
+ # Ensure starting unmuted
+ AudioManager.set_muted(TARGET_BUS, false)
+ AudioManager.apply_volume_to_bus(TARGET_BUS, 1.0, false)
+ sfx_mute_btn.grab_focus()
+ await wait_process_frames(1)
+
+ # Toggle mute ON (button pressed = false in UI mapping)
+ sfx_mute_btn.toggled.emit(false)
+
+ # ASSERT IMMEDIATE LOGICAL FLIP
+ assert_true(AudioManager.get_muted(TARGET_BUS), "Logical state should flip immediately.")
+ assert_false(AudioServer.is_bus_mute(bus_idx), "Hardware cutoff should be deferred to allow click audio to stream out.")
+ assert_true(AudioManager.is_any_sfx_playing(), "Confirmation click SFX should be playing.")
+
+ # AWAIT HARDWARE CUTOFF WINDOW (0.15s + buffer)
+ await wait_seconds(0.2)
+ assert_true(AudioServer.is_bus_mute(bus_idx), "Hardware bus must be muted after safety cutoff timer expires.")
+
+
+## Scenario G: Verify UI mute buttons dynamically sync state without rigid tree paths.
+func test_ui_mute_controls_sync_state_dynamically() -> void:
+ const AUDIO_SETTINGS_PATH = "res://scenes/audio_settings.tscn"
+ assert_true(FileAccess.file_exists(AUDIO_SETTINGS_PATH), "FAIL-FAST: '%s' is missing from the project directory!" % AUDIO_SETTINGS_PATH)
+
+ var audio_settings_scene: = load(AUDIO_SETTINGS_PATH)
+ var audio_menu: = audio_settings_scene.instantiate() as Control
+ assert_not_null(audio_menu, "FAIL-FAST: Instantiated audio settings root node is not a Control type.")
+ add_child_autofree(audio_menu)
+ await wait_process_frames(1)
+
+ # Find interactive checkbuttons dynamically without relying on brittle node paths
+ var check_buttons := audio_menu.find_children("", "CheckButton", true, false)
+ assert_gt(check_buttons.size(), 0, "Audio menu must instantiate interactive CheckButtons.")
+
+ # FIX: Filter specifically for a mute control instead of blindly picking the first index
+ var candidate_btn: CheckButton = null
+ for btn in check_buttons:
+ if "mute" in btn.name.to_lower():
+ candidate_btn = btn as CheckButton
+ break
+
+ assert_not_null(candidate_btn, "Layout Validation: Audio menu must contain at least one CheckButton matching a 'mute' naming convention.")
+ candidate_btn.toggled.emit(false) # Simulate UI mute press
+
+ assert_true(AudioManager.is_any_sfx_playing(), "Triggering any audio menu CheckButton should invoke confirmation SFX.")
diff --git a/test/gut/test_audio_integration.gd.uid b/test/gut/test_audio_integration.gd.uid
new file mode 100644
index 000000000..0c72687de
--- /dev/null
+++ b/test/gut/test_audio_integration.gd.uid
@@ -0,0 +1 @@
+uid://cum8sr3klgyyq
diff --git a/test/gut/test_audio_sfx_centralization.gd b/test/gut/test_audio_sfx_centralization.gd
index 6b2ffb87a..09f4596ed 100644
--- a/test/gut/test_audio_sfx_centralization.gd
+++ b/test/gut/test_audio_sfx_centralization.gd
@@ -5,7 +5,6 @@
## Automated verification suite for Feature Request #570.
## Validates object pooling, LRU cache eviction, failure isolation,
## and constant engine node tree allocation rules.
-
extends "res://addons/gut/test.gd"
var _orig_cache: Dictionary = {}
@@ -27,11 +26,16 @@ func before_each() -> void:
func after_each() -> void:
AudioManager._sfx_cache = _orig_cache
AudioManager._missing_sfx_cache = _orig_missing
+
+ # FIX: Prevent global state pollution if assertions fail early in the test body
+ Globals.options_open = false
+
for p: AudioStreamPlayer in AudioManager._sfx_pool:
p.stop()
-## Verification 1 | A menu sound plays correctly when passing its string identifier to the API.
+## Verification 1 |
+## A menu sound plays correctly when passing its string identifier to the API.
## :rtype: void
func test_verification_01_play_by_identifier() -> void:
var sfx_name: String = "slider"
@@ -46,7 +50,8 @@ func test_verification_01_play_by_identifier() -> void:
assert_true(sound_playing, "An audio player in the pool should be active and streaming the asset.")
-## Verification 2 | Consecutive rapid calls succeed and naturally overlap using separate pool players.
+## Verification 2 |
+## Consecutive rapid calls succeed and naturally overlap using separate pool players.
## :rtype: void
func test_verification_02_consecutive_overlapping_playback() -> void:
AudioManager.play_sfx("slider")
@@ -60,7 +65,8 @@ func test_verification_02_consecutive_overlapping_playback() -> void:
assert_eq(active_players, 2, "Two separate audio players must execute concurrently to allow overlap sounds.")
-## Verification 3 | Flooding the API handles playback gracefully via player hijacking without crashing.
+## Verification 3 |
+## Flooding the API handles playback gracefully via player hijacking without crashing.
## :rtype: void
func test_verification_03_pool_flooding_graceful_hijack() -> void:
# Flood with 10 concurrent requests (Pool size is capped at 8)
@@ -75,7 +81,8 @@ func test_verification_03_pool_flooding_graceful_hijack() -> void:
assert_eq(active_players, AudioManager.SFX_POOL_SIZE, "All pool channels must remain busy without crashing the execution loop.")
-## Verification 4 | Cache bounds are respected: loading a 21st unique SFX successfully evicts the oldest cached stream.
+## Verification 4 |
+## Cache bounds are respected: loading a 21st unique SFX successfully evicts the oldest cached stream.
## :rtype: void
func test_verification_04_lru_cache_eviction_strategy() -> void:
# Populate the internal cache up to the maximum allowable limit (20 entries)
@@ -93,7 +100,8 @@ func test_verification_04_lru_cache_eviction_strategy() -> void:
assert_true(AudioManager._sfx_cache.has("slider"), "The newly parsed asset payload must successfully occupy the cache structure.")
-## Verification 5 | Requesting a non-existent SFX logs a warning once, caches the failure, and suppresses repeated disk lookups.
+## Verification 5 |
+## Requesting a non-existent SFX logs a warning once, caches the failure, and suppresses repeated disk lookups.
## :rtype: void
func test_verification_05_missing_asset_failure_suppression() -> void:
var fake_sfx: String = "invalid_ghost_sound"
@@ -108,7 +116,8 @@ func test_verification_05_missing_asset_failure_suppression() -> void:
assert_false(AudioManager._sfx_cache.has(fake_sfx), "The API must short-circuit and completely bypass disk access routines for known failure keys.")
-## Verification 6 | The total number of AudioStreamPlayer child nodes remains constant before, during, and after playback.
+## Verification 6 |
+## The total number of AudioStreamPlayer child nodes remains constant before, during, and after playback.
## :rtype: void
func test_verification_06_pool_node_count_constancy() -> void:
var initial_count: int = AudioManager.get_child_count()
@@ -122,3 +131,55 @@ func test_verification_06_pool_node_count_constancy() -> void:
assert_eq(initial_count, mid_count)
assert_eq(mid_count, final_count, "The structural node tree footprint under AudioManager must remain completely constant.")
+
+
+## Verification 7 |
+## Validates that play_sfx resolves logical identifiers to their explicit file extensions using the asset map.
+## :rtype: void
+func test_play_sfx_resolves_via_asset_map() -> void:
+ # Ensure the cache doesn't skew results
+ AudioManager.cleanup_for_test()
+
+ # Test an explicit .ogg file defined in our map
+ # AudioManager should look for 'airplane_prop.ogg' instead of 'airplane_prop.wav'
+ AudioManager.play_sfx("airplane_prop")
+
+ var active_path := AudioManager.get_active_sfx_stream_path()
+ assert_string_contains(active_path, "airplane_prop.ogg", "AudioManager should resolve mapping to exact extension found in asset map.")
+
+
+## Verification 8 |
+## Validates that fallback handling automatically appends the default .wav extension to unmapped sound identifiers.
+## :rtype: void
+func test_play_sfx_unmapped_legacy_fallback() -> void:
+ AudioManager.cleanup_for_test()
+
+ # FIX: Pass an unmapped logical string identifier ("check") instead of a mapped one ("slider")
+ # to correctly exercise the .wav fallback branch.
+ AudioManager.play_sfx("check")
+
+ var active_path := AudioManager.get_active_sfx_stream_path()
+ assert_string_contains(active_path, "check.wav", "Unmapped IDs should automatically append .wav for fallback compatibility.")
+
+
+## Verification 9 |
+## Validates that high-frequency mouse motion inputs are dropped out of the global input process loop to optimize performance.
+## :rtype: void
+func test_input_ignores_mouse_motion() -> void:
+ AudioManager.cleanup_for_test()
+
+ # Simulate entering a menu layer to activate context checks
+ Globals.options_open = true
+
+ # Construct a generic high-frequency mouse movement packet
+ var mouse_event := InputEventMouseMotion.new()
+ mouse_event.position = Vector2(250, 450)
+ mouse_event.relative = Vector2(5, 5)
+
+ # Route the fake event pack directly into our global tracker
+ Globals._input(mouse_event)
+
+ # Ensure no execution churn occurred and no playback pool frames were hijacked
+ assert_false(AudioManager.is_any_sfx_playing(), "Mouse motion wiggles must drop immediately out of the input loop without triggering audio players.")
+
+ # REMOVED: Inline cleanup deleted from here to rely safely on shared teardown execution loops.
diff --git a/test/gut/test_globals_button_hooks.gd b/test/gut/test_globals_button_hooks.gd
new file mode 100644
index 000000000..8184d7115
--- /dev/null
+++ b/test/gut/test_globals_button_hooks.gd
@@ -0,0 +1,226 @@
+## Copyright (C) 2026 Egor Kostan
+## SPDX-License-Identifier: GPL-3.0-or-later
+## test_globals_button_hooks.gd
+##
+## Architectural test suite verifying the automatic stream connection guards
+## inside the global Node instantiation tracking track.
+extends "res://addons/gut/test.gd"
+
+
+func before_each() -> void:
+ AudioManager.stop_all_sfx()
+
+
+func after_each() -> void:
+ AudioManager.stop_all_sfx()
+ await _wait_for_registration()
+
+
+# ==========================================================================
+# 1. TIMING, LIFECYCLE & STRUCTURAL HELPERS
+# ==========================================================================
+
+## Shared helper to handle multi-frame deferral windows safely.
+func _wait_for_registration() -> void:
+ await wait_process_frames(2)
+
+
+## Counts how many times the global audio hook is connected to a specific button.
+## Optionally validates that the connection strictly uses the CONNECT_DEFERRED flag.
+func _get_global_connection_count(btn: Button, require_deferred: bool = true) -> int:
+ var count: int = 0
+ for connection: Dictionary in btn.pressed.get_connections():
+ var callable: Callable = connection.get("callable", Callable())
+ if callable == Globals._on_global_button_pressed:
+ if require_deferred:
+ var flags: int = connection.get("flags", 0)
+ if flags & CONNECT_DEFERRED:
+ count += 1
+ else:
+ count += 1
+ return count
+
+
+# ==========================================================================
+# 2. REGISTRATION CONTROLS (INCLUSION & EXCLUSION RULES)
+# ==========================================================================
+
+## Regular runtime UI buttons should auto-bind to the global audio layer exactly once.
+func test_standard_button_auto_connects_exactly_once() -> void:
+ # Arrange & Act
+ var standard_btn := Button.new()
+ add_child_autofree(standard_btn)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(standard_btn),
+ 1,
+ "Standard interactive buttons must have exactly one connection to the global handler."
+ )
+
+
+## Flat UI theme buttons must be exempted from global audio routing.
+func test_flat_button_is_ignored_by_hook() -> void:
+ # Arrange & Act
+ var flat_btn := Button.new()
+ flat_btn.flat = true
+ add_child_autofree(flat_btn)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(flat_btn),
+ 0,
+ "Flat buttons must bypass global audio registration."
+ )
+
+
+## Buttons carrying 'no_global_sound' metadata must remain isolated.
+func test_meta_flagged_button_is_ignored_by_hook() -> void:
+ # Arrange & Act
+ var isolated_btn := Button.new()
+ isolated_btn.set_meta("no_global_sound", true)
+ add_child_autofree(isolated_btn)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(isolated_btn),
+ 0,
+ "Metadata-flagged buttons must bypass global audio registration."
+ )
+
+
+## Internal dialog buttons should never receive global audio hooks.
+func test_dialog_internal_buttons_are_ignored_by_hook() -> void:
+ # Arrange & Act
+ var confirmation_dialog := ConfirmationDialog.new()
+ add_child_autofree(confirmation_dialog)
+
+ var ok_btn := confirmation_dialog.get_ok_button()
+ var cancel_btn := confirmation_dialog.get_cancel_button()
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(_get_global_connection_count(ok_btn), 0, "Dialog OK button must ignore global hooks.")
+ assert_eq(_get_global_connection_count(cancel_btn), 0, "Dialog Cancel button must ignore global hooks.")
+
+
+## Architectural Policy: Engine button subclasses are intentionally excluded
+## from automatic global audio routing to prevent double audio tracking against local triggers.
+func test_button_subclass_is_ignored_by_strict_class_contract() -> void:
+ # Arrange & Act
+ var custom_btn := CheckButton.new()
+ add_child_autofree(custom_btn)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(custom_btn),
+ 0,
+ "Engine button subclasses must bypass global audio registration matching rules."
+ )
+
+
+# ==========================================================================
+# 3. SCENE-TREE LIFECYCLE CONTROLS (MUTATION & REGRESSION PROTECTION)
+# ==========================================================================
+
+## Moving a node inside the active scene layout tree must not generate duplicate connection tracks.
+func test_reparenting_does_not_create_duplicate_connections() -> void:
+ # Arrange
+ var parent_a := Control.new()
+ var parent_b := Control.new()
+ var btn := Button.new()
+
+ add_child_autofree(parent_a)
+ add_child_autofree(parent_b)
+ parent_a.add_child(btn)
+ await _wait_for_registration()
+
+ assert_eq(_get_global_connection_count(btn), 1, "Precondition: Single tracking baseline established.")
+
+ # Act
+ btn.reparent(parent_b)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(btn),
+ 1,
+ "Reparenting a node inside the scene tree must not introduce duplicate signal connections."
+ )
+
+
+## Manually re-triggering tree scan events against an already tracked node must remain idempotent.
+func test_duplicate_scan_calls_do_not_duplicate_connections() -> void:
+ # Arrange
+ var btn := Button.new()
+ add_child_autofree(btn)
+ await _wait_for_registration()
+ assert_eq(_get_global_connection_count(btn), 1, "Precondition: Single link registered.")
+
+ # Act
+ # This intentionally exercises the internal callback because duplicate registration
+ # protection loops live entirely within that routine block.
+ Globals._on_node_added(btn)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(btn),
+ 1,
+ "Repeated tree tracking passes must guard against duplicate audio stream connections."
+ )
+
+
+## Architectural Constraint: Runtime metadata updates applied post-entrance are not reactive.
+func test_post_registration_metadata_changes_do_not_retroactively_disconnect() -> void:
+ # Arrange
+ var btn := Button.new()
+ add_child_autofree(btn)
+ await _wait_for_registration()
+ assert_eq(_get_global_connection_count(btn), 1, "Precondition: Normal hook assigned on entrance.")
+
+ # Act
+ btn.set_meta("no_global_sound", true)
+ await _wait_for_registration()
+
+ # Assert
+ assert_eq(
+ _get_global_connection_count(btn),
+ 1,
+ "Architectural Constraint: Runtime metadata updates applied post-entrance are not reactive."
+ )
+
+
+# ==========================================================================
+# 4. CLEANUP & MEMORY RESILIENCE
+# ==========================================================================
+
+## Verifies that freeing a registered node doesn't corrupt state or pollute tracking systems
+## when subsequent identical button nodes enter the tree.
+func test_node_destruction_cleanup_is_safe() -> void:
+ # Arrange
+ var initial_btn := Button.new()
+ # Upgraded to autofree to prevent scene tree pollution if the precondition assertion fails
+ add_child_autofree(initial_btn)
+ await _wait_for_registration()
+ assert_eq(_get_global_connection_count(initial_btn), 1, "Precondition: First button hooked up cleanly.")
+
+ # Act: Deallocate the node from memory completely
+ initial_btn.queue_free()
+ await _wait_for_registration()
+
+ # Instantiate a replacement button control
+ var fresh_btn := Button.new()
+ add_child_autofree(fresh_btn)
+ await _wait_for_registration()
+
+ # Assert: Tracking baseline re-registers perfectly without bleeding stale links
+ assert_eq(
+ _get_global_connection_count(fresh_btn),
+ 1,
+ "Memory Cleanup Failed: Node destruction caused tracking anomalies or double connection leaks on fresh nodes."
+ )
diff --git a/test/gut/test_globals_button_hooks.gd.uid b/test/gut/test_globals_button_hooks.gd.uid
new file mode 100644
index 000000000..c54ab81e3
--- /dev/null
+++ b/test/gut/test_globals_button_hooks.gd.uid
@@ -0,0 +1 @@
+uid://b1lmso0hluxti
diff --git a/test/gut/test_globals_input_guards.gd b/test/gut/test_globals_input_guards.gd
new file mode 100644
index 000000000..62f8cf6bd
--- /dev/null
+++ b/test/gut/test_globals_input_guards.gd
@@ -0,0 +1,183 @@
+## Copyright (C) 2026 Egor Kostan
+## SPDX-License-Identifier: GPL-3.0-or-later
+## test_globals_input_guards.gd
+##
+## Integration test suite validating that global ui_accept input intercepts
+## are correctly bypassed for specific interactive UI controls to eliminate
+## duplicate sound triggers and unintended activation noises.
+
+## ==========================================================================
+## ARCHITECTURAL CONTRACT
+## ==========================================================================
+## These tests verify the boundary between the global input interceptor
+## and individual interactive Controls that manage their own audio streams.
+## Any Control responsible for triggering its own local confirmation or click
+## sounds must be bypassed by the global ui_accept handler to prevent
+## duplicate audio playback ("double-dipping").
+
+extends "res://addons/gut/test.gd"
+
+var _original_options_open: bool
+
+
+func before_each() -> void:
+ # Snapshot state and force active menu context for predictable test execution
+ _original_options_open = Globals.options_open
+ Globals.options_open = true
+
+ # Purge any running streams from the shared singleton pool
+ AudioManager.stop_all_sfx()
+ if AudioManager.has_method("cleanup_for_test"):
+ AudioManager.cleanup_for_test()
+
+
+func after_each() -> void:
+ Globals.options_open = _original_options_open
+
+ var focus_owner := get_viewport().gui_get_focus_owner()
+ if is_instance_valid(focus_owner):
+ focus_owner.release_focus()
+
+ AudioManager.stop_all_sfx()
+ # Micro-optimization: Removed 'await get_tree().process_frame'
+ # unless cross-suite focus pollution explicitly requires it.
+
+
+# ==========================================================================
+# TEST HELPERS
+# ==========================================================================
+
+## Factory method to assemble an isolated ui_accept InputEventAction payload
+func _create_ui_accept_event() -> InputEventAction:
+ var event := InputEventAction.new()
+ event.action = "ui_accept"
+ event.pressed = true
+ return event
+
+
+## Handles boilerplate instantiation, tree attachment, and frame-buffered focus allocation
+func _setup_focused_control(control: Control) -> void:
+ add_child_autofree(control)
+
+ # Godot 4 Fix: Force passive controls (like Panel) to allow focus allocation for baseline testing
+ if control.focus_mode == Control.FOCUS_NONE:
+ control.focus_mode = Control.FOCUS_ALL
+
+ control.grab_focus()
+
+ # Godot requires exactly one frame step to recalculate and assign viewport focus owners
+ await get_tree().process_frame
+
+ assert_eq(get_viewport().gui_get_focus_owner(), control, "Precondition: Target control must hold active focus.")
+ AudioManager.stop_all_sfx()
+
+
+# ==========================================================================
+# TEST SCENARIOS
+# ==========================================================================
+
+## Issue #1 Verification: Toggling a mute button should bypass the global
+## ui_accept sound, delegating downstream audio feedback purely to its local pipeline.
+func test_check_button_focus_skips_global_audio() -> void:
+ var mute_btn := CheckButton.new()
+ await _setup_focused_control(mute_btn)
+
+ # Feed the event directly into Globals._input().
+ # GUT does not cleanly pump synthetic InputEventActions through the engine's main OS window
+ # pipeline, so this exercises the target script's entry point directly and deterministically.
+ Globals._input(_create_ui_accept_event())
+
+ assert_false(
+ AudioManager.is_any_sfx_playing(),
+ "Fix Verification Failed: Global pipeline played generic audio over a focused CheckButton toggle switch."
+ )
+
+
+## Issue #2 Verification: Pressing Enter or Spacebar while a volume slider
+## holds focus must remain entirely silent.
+func test_slider_focus_skips_global_audio() -> void:
+ var slider := HSlider.new()
+ await _setup_focused_control(slider)
+
+ Globals._input(_create_ui_accept_event())
+
+ assert_false(
+ AudioManager.is_any_sfx_playing(),
+ "Fix Verification Failed: Slider emitted generic ui_accept audio when activated via keyboard/controller."
+ )
+
+
+## Issue #3 Verification: Hitting Enter on standard menu buttons must block
+## the global input hook from playing audio, preventing back-to-back double triggers.
+func test_button_focus_skips_global_audio() -> void:
+ var menu_btn := Button.new()
+ await _setup_focused_control(menu_btn)
+
+ Globals._input(_create_ui_accept_event())
+
+ assert_false(
+ AudioManager.is_any_sfx_playing(),
+ "Fix Verification Failed: Global hook double-dipped audio on a standard Button element."
+ )
+
+
+## Regression Guard: Ensure the input block checks the abstract BaseButton class,
+## preventing audio leakage on alternative button implementations like TextureButtons.
+func test_texture_button_focus_skips_global_audio() -> void:
+ var texture_btn := TextureButton.new()
+ await _setup_focused_control(texture_btn)
+
+ Globals._input(_create_ui_accept_event())
+
+ assert_false(
+ AudioManager.is_any_sfx_playing(),
+ "Regression Failure: Input guard failed to catch TextureButton (likely checking for Button instead of BaseButton)."
+ )
+
+
+## Control Baseline Case: Ensure standard passive UI layout containers or non-interactive
+## items still register the global fallback accept sound cleanly.
+func test_passive_control_triggers_global_audio() -> void:
+ var fallback_control := Panel.new()
+ await _setup_focused_control(fallback_control)
+
+ Globals._input(_create_ui_accept_event())
+
+ assert_true(
+ AudioManager.is_any_sfx_playing(),
+ "Control Parity Failure: Global ui_accept tracking was blocked by an overly broad filter check."
+ )
+
+
+# ==========================================================================
+# DIRECTIONAL NAVIGATION & STALE FOCUS COVERAGE (Sourcery Verification)
+# ==========================================================================
+
+## Helper method to assemble a navigation InputEventAction payload
+func _create_nav_event(action_name: String) -> InputEventAction:
+ var event := InputEventAction.new()
+ event.action = action_name
+ event.pressed = true
+ return event
+
+
+## Verification: Directional navigation sound effects must not be dropped
+## when viewport focus is transiently empty/stale during a menu context.
+func test_stale_focus_navigation_retains_audio_safeguard() -> void:
+ # 1. Force a valid menu context but ensure focus owner is explicitly empty/stale
+ Globals.options_open = true
+ var focus_owner := get_viewport().gui_get_focus_owner()
+ if is_instance_valid(focus_owner):
+ focus_owner.release_focus()
+
+ # Clear out any previous audio junk
+ AudioManager.stop_all_sfx()
+
+ # 2. Simulate a user hitting 'ui_down' during a focus transition fade
+ Globals._input(_create_nav_event("ui_down"))
+
+ # 3. Assert that the safeguard caught the transition and played the fallback audio
+ assert_true(
+ AudioManager.is_any_sfx_playing(),
+ "Safeguard Failure: Navigation audio was unintentionally dropped due to a stale UI focus state."
+ )
diff --git a/test/gut/test_globals_input_guards.gd.uid b/test/gut/test_globals_input_guards.gd.uid
new file mode 100644
index 000000000..499628781
--- /dev/null
+++ b/test/gut/test_globals_input_guards.gd.uid
@@ -0,0 +1 @@
+uid://lsdqr7yspe54
diff --git a/test/gut/test_integration_key_mapping.gd b/test/gut/test_integration_key_mapping.gd
index c11dbef6e..45d53ff4d 100644
--- a/test/gut/test_integration_key_mapping.gd
+++ b/test/gut/test_integration_key_mapping.gd
@@ -15,40 +15,56 @@
extends GutTest
+# ==========================================================================
+# Member Variables & Preloads
+# ==========================================================================
const InputRemapButton: Script = preload(GamePaths.INPUT_REMAP_BUTTON)
-const TEST_ACTION: String = "speed_up" # Example action from UI; adjust if needed.
+const TEST_ACTION: String = "speed_up"
const TEST_CONFIG_PATH: String = "user://settings.cfg"
-const KEY_W_CODE: int = Key.KEY_W # 87, default assumed.
-const KEY_Z_CODE: int = Key.KEY_Z # 90, custom for tests.
+const KEY_W_CODE: int = Key.KEY_W
+const KEY_Z_CODE: int = Key.KEY_Z
var menu: CanvasLayer = null
+# New variable to prevent cross-suite state leakage
+var _original_input_device: String = "keyboard"
-## Suite-wide setup: Backup production config if exists to preserve user settings.
-## :rtype: void
+# ==========================================================================
+# Lifecycle & Setup / Teardown
+# ==========================================================================
+
+## Suite-wide setup: Cache global state and backup production config.
func before_all() -> void:
+ # Cache the current state before this suite modifies it
+ if is_instance_valid(Globals):
+ _original_input_device = Globals.current_input_device
+
var backup_path: String = "user://settings_backup.cfg"
if FileAccess.file_exists(TEST_CONFIG_PATH):
var err: Error = DirAccess.copy_absolute(TEST_CONFIG_PATH, backup_path)
assert_eq(err, OK, "Failed to backup config: " + str(err))
-## Per-test setup: Delete test config, reset InputMap for test action.
-## :rtype: void
+## Per-test setup: Reset global device tracking state and clear test configurations.
func before_each() -> void:
- if FileAccess.file_exists(TEST_CONFIG_PATH):
- var err: Error = DirAccess.remove_absolute(TEST_CONFIG_PATH)
- assert_eq(err, OK)
- for action in Settings.ACTIONS:
- if InputMap.has_action(action):
- InputMap.action_erase_events(action)
+ # Enforce a clean keyboard baseline for each test within this suite
+ if is_instance_valid(Globals):
+ Globals.current_input_device = "keyboard"
+
+ if FileAccess.file_exists(TEST_CONFIG_PATH):
+ var err: Error = DirAccess.remove_absolute(TEST_CONFIG_PATH)
+ assert_eq(err, OK)
+
+ for action in Settings.ACTIONS:
+ if InputMap.has_action(action):
+ InputMap.action_erase_events(action)
else:
- InputMap.add_action(action)
- Settings.load_input_mappings() # Reload empty
+ InputMap.add_action(action)
+
+ Settings.load_input_mappings()
-## Per-test cleanup: Free menu, delete test config.
-## :rtype: void
+## Per-test cleanup: Free menu and delete test configuration files.
func after_each() -> void:
if is_instance_valid(menu):
menu.queue_free()
@@ -58,9 +74,12 @@ func after_each() -> void:
await get_tree().process_frame
-## Suite-wide cleanup: Restore production config from backup if it existed.
-## :rtype: void
+## Suite-wide cleanup: Restore original global input device and production config backup.
func after_all() -> void:
+ # RESTORE: Safely put back the global state exactly how we found it
+ if is_instance_valid(Globals):
+ Globals.current_input_device = _original_input_device
+
var backup_path: String = "user://settings_backup.cfg"
if FileAccess.file_exists(backup_path):
if FileAccess.file_exists(TEST_CONFIG_PATH):
diff --git a/test/gut/test_nav_escape_sfx.gd b/test/gut/test_nav_escape_sfx.gd
index 02f934ebc..ae1b9ff24 100644
--- a/test/gut/test_nav_escape_sfx.gd
+++ b/test/gut/test_nav_escape_sfx.gd
@@ -1,9 +1,8 @@
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## test_nav_escape_sfx.gd
-## GUT unit tests for global navigation and escape audio routing gates.
-
-extends "res://addons/gut/test.gd"
+# FIX: Swapped out explicit path inheritance for the global class name token to ensure test runner discovery
+extends GutTest
var globals_instance: Node
var original_audio_script: Script
@@ -86,7 +85,8 @@ func _assert_sfx_called(key: String) -> void:
func _assert_sfx_call_count(count: int) -> void:
- var actual_count: int = AudioManager.get("sfx_calls").size()
+ var actual_count: int = AudioManager.get("sfx_calls").size()
+ # FIX: Combined split string literal into a single line to resolve the engine compilation crash
assert_eq(actual_count, count, "Expected play_sfx to be called %d times. Got %d." % [count, actual_count])
diff --git a/test/gut/test_quit_game_confirm_dialog_sfx.gd b/test/gut/test_quit_game_confirm_dialog_sfx.gd
index a624d298f..0762801e3 100644
--- a/test/gut/test_quit_game_confirm_dialog_sfx.gd
+++ b/test/gut/test_quit_game_confirm_dialog_sfx.gd
@@ -2,8 +2,8 @@
## SPDX-License-Identifier: GPL-3.0-or-later
## test_quit_game_confirm_dialog_sfx.gd
## GUT unit tests for main menu quit dialog confirmation audio pathways.
-
-extends "res://addons/gut/test.gd"
+# FIX: Swapped out explicit path inheritance for the global class name token to ensure test runner discovery
+extends GutTest
# FIX: Load the PackedScene layout to build the required @onready subnode tree hierarchy
var MainMenuScene: PackedScene = load("res://scenes/main_menu.tscn")
diff --git a/workspace/run_unit_tests.sh b/workspace/run_unit_tests.sh
deleted file mode 100644
index 592465c6f..000000000
--- a/workspace/run_unit_tests.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash
-# Copyright (C) 2025 Egor Kostan
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-PROJECT_DIR="/project"
-
-echo "Ensuring GDUnit4 in project addons..."
-cp -r /project/addons/gdUnit4 $PROJECT_DIR/addons/ || true
-
-echo "Importing Resources..."
-godot --headless --path $PROJECT_DIR --import --quit
-if [ $? -ne 0 ]; then echo "Resource import failed."; exit 1; fi
-
-echo "Running GDUnit4 Tests..."
-godot --headless --path $PROJECT_DIR -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode --add res://test/gdunit4
-if [ $? -ne 0 ]; then echo "Unit tests failed."; exit 1; fi
-
-# Simulate artifact: Copy reports
-mkdir -p $PROJECT_DIR/reports
-cp -r reports/** $PROJECT_DIR/reports || true
-
-echo "Godot Unit Tests completed!"