diff --git a/.all-contributorsrc b/.all-contributorsrc index bf6e2695a..fcc323b3f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -6,5 +6,6 @@ "files": ["README.md"], "imageSize": 100, "contributorsPerLine": 7, + "contributors": [], "badgeTemplate": "[/<%= projectName %>?color=ee8449&style=flat-square\" >](<%= contributorsUrl %>)" } \ No newline at end of file diff --git a/README.md b/README.md index d0af7d3dd..29576f7e3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ You can play this game on [Itch.io](https://ikostan.itch.io/sky-lock-assault) - [Release Drafter](https://github.com/release-drafter/release-drafter?tab=readme-ov-file#readme) - [Close Stale Issues and PRs](https://github.com/actions/stale) - [AllContributors GitHub App](https://allcontributors.org/docs/en/bot/installation) + - [DeepSource](https://github.com/deepsource) 9. [Free Web Browser Game Deployment Platforms](files/docs/Platforms_for_Web_Deployment_Guide.md) @@ -145,6 +146,21 @@ these GPL requirements, a separate license is available upon request. - Observer-based Settings System: Centralized GameSettingsResource that handles automatic persistence and UI synchronization through signals. + +### Project Structure (`scripts/`) + +Post-Refactor Phase 4 (PR `#582`), the root `scripts/` directory has been fully +reorganised into purpose-specific sub-directories: + +| Directory | Contents | +|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `scripts/core/` | Foundational systems: `game_paths.gd` (centralized path registry), `globals.gd`, `main_scene.gd`, `settings.gd` | +| `scripts/resources/` | Data containers & configuration: `game_settings_resource.gd`, `audio_constants.gd` | +| `scripts/entities/` | Game objects: `player.gd`, `bullet.gd`, `weapon.gd` | +| `scripts/system/` | Platform wrappers & integrations: `audio_web_bridge.gd`, `JavaScriptBridgeWrapper.gd`, `OSWrapper.gd` | +| `scripts/managers/` | Game-loop managers: `audio_manager.gd`, `parallax_manager.gd`, `resource_preloader.gd` | +| `scripts/ui/` | Interface layer: `hud.gd`; sub-dirs `menus/` (main, pause, options, audio, gameplay, key-mapping, advanced), `screens/` (splash, loading), `components/` (volume slider, input remap button) | + --- ## 🟢 Current Development Status @@ -289,6 +305,19 @@ to user input devices: - Modifier-aware remapping requires explicit key+modifier press for unique bindings. +### Milestone 14 + +**Status:** Stable gameplay loop with synced UI systems and GUT-based +unit testing. +**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels). +**Version:** v0.9.18 + +### Milestone 16 + +**Status:** Stable gameplay loop with fully refactored script architecture, +synced UI systems, and GUT-based unit testing. +**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels). + Track progress via [Milestones](https://github.com/ikostan/SkyLockAssault/milestones). --- diff --git a/files/sounds/sfx/slider.wav b/files/sounds/sfx/slider.wav new file mode 100644 index 000000000..c3f3a34a0 Binary files /dev/null and b/files/sounds/sfx/slider.wav differ diff --git a/files/sounds/sfx/slider.wav.import b/files/sounds/sfx/slider.wav.import new file mode 100644 index 000000000..be6f9da71 --- /dev/null +++ b/files/sounds/sfx/slider.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://6asmf6p6ftp5" +path="res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample" + +[deps] + +source_file="res://files/sounds/sfx/slider.wav" +dest_files=["res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/scripts/core/globals.gd b/scripts/core/globals.gd index 518009a77..a108a3a09 100644 --- a/scripts/core/globals.gd +++ b/scripts/core/globals.gd @@ -377,7 +377,8 @@ 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? - var ui_has_focus: bool = is_instance_valid(get_viewport().gui_get_focus_owner()) + 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 var is_menu_context: bool = ( @@ -392,6 +393,11 @@ func _input(_event: InputEvent) -> void: # We use the global Input singleton here because it perfectly handles # analog joystick deadzone debouncing, which event.is_echo() misses. if Input.is_action_just_pressed(action): + # NEW: Prevent double-audio when adjusting sliders. + # If a slider has focus, left/right adjusts the value instead of navigating. + if focus_owner is Slider and (action == "ui_left" or action == "ui_right"): + return + _play_ui_navigation_sfx() return # Exit once sound is triggered to avoid double-plays diff --git a/scripts/managers/audio_manager.gd b/scripts/managers/audio_manager.gd index 888329fae..fd8297a9b 100644 --- a/scripts/managers/audio_manager.gd +++ b/scripts/managers/audio_manager.gd @@ -12,6 +12,16 @@ signal volume_changed(bus_name: String, volume: float) signal mute_toggled(bus_name: String, is_muted: bool) # -------------------------------------------- +# --- NEW: SFX CACHING & MANAGEMENT --- +## Base path for all UI sound effects. +const SFX_DIR_PATH: String = "res://files/sounds/sfx/" + +## Hard cap for cached SFX streams to prevent unbounded memory growth. +const MAX_SFX_CACHE_SIZE: int = 20 + +## Number of reusable AudioStreamPlayers to keep in memory for UI sounds. +const SFX_POOL_SIZE: int = 8 + @export_category("Master Volume") @export var master_volume: float @export var master_muted: bool @@ -32,14 +42,29 @@ signal mute_toggled(bus_name: String, is_muted: bool) var current_config_path: String = Settings.CONFIG_PATH +# --- SFX CACHE STATE --- +## Dictionary to store preloaded AudioStreams to prevent disk I/O stutter. +var _sfx_cache: Dictionary = {} + +## Dictionary acting as a set to track missing SFX and prevent repeated load attempts/log spam. +var _missing_sfx_cache: Dictionary = {} + +## Array of pre-instantiated AudioStreamPlayers to prevent node instantiation churn. +var _sfx_pool: Array[AudioStreamPlayer] = [] + func _ready() -> void: ## Initializes to defaults and loads/applies volumes. - ## :rtype: void _init_to_defaults() # Set to defaults from AudioConstants load_volumes() # Load persisted volumes (overrides defaults if saved) apply_all_volumes() # Apply to AudioServer buses + # Initialize the SFX object pool + for i in range(SFX_POOL_SIZE): + var p := AudioStreamPlayer.new() + add_child(p) + _sfx_pool.append(p) + ## Initialize all volumes and mutes to defaults from AudioConstants ## :rtype: void @@ -333,3 +358,81 @@ func reset_volumes() -> void: apply_all_volumes() save_volumes() Globals.log_message("Audio volumes reset to defaults.", Globals.LogLevel.DEBUG) + + +## Centralized SFX Playback API (Issue #565) +## Handles non-positional audio with LRU caching and auto-cleanup. +## :param sfx_name: The filename without extension (e.g., "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. +func play_sfx( + sfx_name: String, + bus_name: String = AudioConstants.BUS_SFX_MENU, + pitch_scale: float = 1.0, + volume_db: float = 0.0 +) -> void: + if sfx_name.is_empty(): + return + + # Short-circuit: If we already know this file is missing, do not attempt to load it again. + if _missing_sfx_cache.has(sfx_name): + return + + # 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" + var stream: AudioStream = load(full_path) + + if stream: + # Eviction strategy: If cache is full, remove the oldest (first) entry + if _sfx_cache.size() >= MAX_SFX_CACHE_SIZE: + var oldest_key: String = _sfx_cache.keys()[0] + _sfx_cache.erase(oldest_key) + Globals.log_message( + "SFX cache full. Evicted: " + oldest_key, Globals.LogLevel.DEBUG + ) + + _sfx_cache[sfx_name] = stream + else: + Globals.log_message( + "SFX file not found or failed to load: " + full_path, Globals.LogLevel.WARNING + ) + # Cache the failure so we don't spam the disk and logs on subsequent requests + _missing_sfx_cache[sfx_name] = true + return + else: + # LRU Update: Godot 4 Dictionaries preserve insertion order. + # By erasing and re-inserting, we push this active sound to the "newest" end of the dictionary. + var stream: AudioStream = _sfx_cache[sfx_name] + _sfx_cache.erase(sfx_name) + _sfx_cache[sfx_name] = stream + + # 2. Grab an available player from the object pool + var player: AudioStreamPlayer = null + for p: AudioStreamPlayer in _sfx_pool: + if not p.playing: + player = p + break + + # Fallback: If all players are busy, hijack the first one in the pool + # to prevent dropping the new sound entirely. + if player == null: + player = _sfx_pool[0] + + player.stream = _sfx_cache[sfx_name] + player.pitch_scale = pitch_scale + player.volume_db = volume_db + + # 3. Bus Validation & Routing + if AudioServer.get_bus_index(bus_name) == -1: + Globals.log_message( + "Invalid bus '%s' requested for SFX. Falling back to SFX_Menu." % bus_name, + Globals.LogLevel.WARNING + ) + player.bus = AudioConstants.BUS_SFX_MENU + else: + player.bus = bus_name + + # 4. Play (No queue_free needed since we reuse the nodes) + player.play() diff --git a/scripts/resources/audio_constants.gd b/scripts/resources/audio_constants.gd index bec134db2..c8f7883d6 100644 --- a/scripts/resources/audio_constants.gd +++ b/scripts/resources/audio_constants.gd @@ -7,6 +7,7 @@ extends Node +# --- Audio Bus Names --- const BUS_MASTER: String = "Master" const BUS_MUSIC: String = "Music" const BUS_SFX: String = "SFX" @@ -14,6 +15,11 @@ const BUS_SFX_ROTORS: String = "SFX_Rotors" const BUS_SFX_WEAPON: String = "SFX_Weapon" const BUS_SFX_MENU: String = "SFX_Menu" +# --- SFX Asset IDs --- +const SFX_SLIDER: String = "slider" +const SFX_MUTE_TOGGLE: String = "mute_toggle" # For future CheckButton task +const SFX_UI_NAVIGATION: String = "ui_navigation" + # Centralized config with defaults and var mappings const BUS_CONFIG: Dictionary = { BUS_MASTER: diff --git a/scripts/ui/components/volume_slider.gd b/scripts/ui/components/volume_slider.gd index d90d11409..48a0188ea 100644 --- a/scripts/ui/components/volume_slider.gd +++ b/scripts/ui/components/volume_slider.gd @@ -1,48 +1,204 @@ ## Copyright (C) 2025 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later -# New: Register as global class for testing and reuse -class_name VolumeSlider +## volume_slider.gd +## +## Handles the volume control slider UI component. +## Sends volume updates to AudioManager and handles debounced saving. +## Plays rate-limited SFX exclusively on manual user interactions, +## safely ignoring programmatic volume changes to prevent audio spam. +class_name VolumeSlider extends HSlider +## The cooldown in milliseconds to prevent audio spam during rapid slider drags. +const SFX_COOLDOWN_MS: int = 60 + +## The name of the audio bus this slider controls (e.g., "Master", "Music"). @export var bus_name: String + +## The internal index of the audio bus, resolved at runtime. var bus_index: int -# New: Debounce timer for saving settings +## Debounce timer for saving settings to avoid disk I/O spam during continuous sliding. var save_debounce_timer: Timer +# --- SFX Rate Limiting and State --- + +## Timestamp of the last played SFX to enforce the cooldown. +var _last_sfx_time: int = 0 +## Tracks the previous value to ensure SFX only plays on actual deltas. +var _previous_value: float = -1.0 + +## Tracks whether the user is actively holding the mouse button down over the slider. +var _is_dragging: bool = false + + +## Initializes the slider, resolves the bus index, syncs the initial value without +## triggering signals, and sets up the debounce timer. +## :rtype: void func _ready() -> void: # Get bus id by name bus_index = AudioServer.get_bus_index(bus_name) + # Guard against invalid audio bus names to avoid runtime errors + if bus_index == -1: + var err_msg: String = ( + "VolumeSlider Error: Invalid audio bus name '%s'. Disabling slider." % bus_name + ) + Globals.log_message(err_msg, Globals.LogLevel.ERROR) + + # Kill all interactions on this dead component + editable = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + return + # Set current bus volume value first (without triggering signal yet) - value = db_to_linear(AudioServer.get_bus_volume_db(bus_index)) + var initial_val: float = db_to_linear(AudioServer.get_bus_volume_db(bus_index)) + _previous_value = initial_val + value = initial_val # Now connect signal for future changes value_changed.connect(_on_value_changed) - # New: Initialize debounce timer (0.5s delay, one-shot) + # Safely track input without overriding the base _gui_input virtual method + gui_input.connect(_on_gui_input) + + # Initialize debounce timer (0.5s delay, one-shot) save_debounce_timer = Timer.new() save_debounce_timer.wait_time = 0.5 save_debounce_timer.one_shot = true save_debounce_timer.timeout.connect(_on_debounce_timeout) - add_child(save_debounce_timer) # Add to scene tree for auto-processing + add_child(save_debounce_timer) + + +## Safe method for external scripts to update the slider without triggering SFX or saves. +## Use this instead of modifying `value` directly when restoring settings. +## :param new_value: The target volume (0.0 to 1.0). +## :type new_value: float +## :rtype: void +func set_value_programmatically(new_value: float) -> void: + # Guard against external updates if the bus is invalid + if bus_index == -1: + return + + # Clamp to the slider's configured range to avoid UI/backend divergence + var clamped_value: float = clamp(new_value, min_value, max_value) + + # Early return: Prevent redundant backend I/O if the value hasn't changed + if is_equal_approx(clamped_value, _previous_value): + return + + # Godot 4 native method: updates visual value without emitting 'value_changed' + set_value_no_signal(clamped_value) + + # Explicitly sync the audio backend, since the signal was bypassed + AudioServer.set_bus_volume_db(bus_index, linear_to_db(clamped_value)) + AudioManager.set_volume(bus_name, clamped_value) + + # Sync the delta tracker so the next manual interaction calculates correctly + _previous_value = clamped_value + + +## Tracks mouse and touch drag state for accurate interaction gating, even if the cursor +## leaves the slider's bounding box while dragging. +## :param event: The input event passed by the UI system. +## :type event: InputEvent +## :rtype: void +func _on_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _is_dragging = event.pressed + elif event is InputEventScreenTouch: + # Touch down/up should mirror mouse press/release behavior. + _is_dragging = event.pressed + elif event is InputEventScreenDrag: + # Any active drag implies the pointer is currently dragging the slider. + _is_dragging = true -# change bus value/volume -func _on_value_changed(value: float) -> void: - AudioServer.set_bus_volume_db(bus_index, linear_to_db(value)) - AudioManager.set_volume(bus_name, value) - # New: Start/restart debounce timer instead of immediate save - if save_debounce_timer.is_stopped(): - save_debounce_timer.start() - else: - save_debounce_timer.stop() - save_debounce_timer.start() +## Catches edge cases where input release events are dropped (e.g., ALT-Tabbing +## or unexpected focus stealing), preventing the drag state from getting stuck. +## :param what: The notification ID from the engine. +## :type what: int +## :rtype: void +func _notification(what: int) -> void: + if what == NOTIFICATION_FOCUS_EXIT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: + _is_dragging = false -# New: Called on timer timeout—perform the batched save +## Signal listener for when the slider value changes manually. +## :param new_value: The new volume level from the slider (0.0 to 1.0). +## :type new_value: float +## :rtype: void +func _on_value_changed(new_value: float) -> void: + # Early return: Prevent redundant backend I/O and disk saves on float jitter + if is_equal_approx(new_value, _previous_value): + return + + # Commit the delta tracker immediately + _previous_value = new_value + + AudioServer.set_bus_volume_db(bus_index, linear_to_db(new_value)) + AudioManager.set_volume(bus_name, new_value) + + # Attempt to play interaction feedback + _handle_slider_sfx(new_value) + + # Godot automatically restarts an active timer when start() is called + save_debounce_timer.start() + + +## Guards SFX playback against rapid spam. +## Ensures sound only plays during legitimate, rate-limited user interactions. +## :param new_value: The updated slider value. +## :type new_value: float +## :rtype: void +func _handle_slider_sfx(_new_value: float) -> void: + # Guard 1: Only play if user is actively interacting (Mouse Drag or Keyboard Focus) + var is_mouse_active: bool = _is_dragging + var is_keyboard_active: bool = has_focus() + + if not (is_mouse_active or is_keyboard_active): + return + + # Guard 2: Rate limit to prevent audio spam during rapid drags + var current_time: int = Time.get_ticks_msec() + if current_time - _last_sfx_time < SFX_COOLDOWN_MS: + return + + # Commit time state only after all guards pass + _last_sfx_time = current_time + + # Use get_node to allow for GUT testing/doubling of the autoload + get_node("/root/AudioManager").play_sfx(AudioConstants.SFX_SLIDER) + + +## Called on timer timeout—performs the batched disk save. +## :rtype: void func _on_debounce_timeout() -> void: AudioManager.save_volumes() Globals.log_message("Debounced settings save triggered.", Globals.LogLevel.DEBUG) + + +# ========================================== +# PUBLIC GETTERS FOR TESTING & VALIDATION +# ========================================== + + +## Returns the last recorded delta value used for SFX checks. +## :rtype: float +func get_previous_value() -> float: + return _previous_value + + +## Returns the raw timestamp of the last played interaction sound. +## :rtype: int +func get_last_sfx_time() -> int: + return _last_sfx_time + + +## Returns whether the user is actively dragging the slider UI. +## :rtype: bool +func is_user_dragging() -> bool: + return _is_dragging diff --git a/test/gdunit4/test_volume_slider.gd b/test/gdunit4/test_volume_slider.gd index dd4bbe242..1c595a5a3 100644 --- a/test/gdunit4/test_volume_slider.gd +++ b/test/gdunit4/test_volume_slider.gd @@ -1,4 +1,4 @@ -## Copyright (C) 2025 Egor Kostan +## Copyright (C) 2026 Egor Kostan ## SPDX-License-Identifier: GPL-3.0-or-later ## test_volume_slider.gd ## Unit tests for volume_slider.gd. @@ -11,34 +11,34 @@ extends GdUnitTestSuite var slider: VolumeSlider +# Explicitly preload the script to bypass GdUnit4's class_name registry bugs +const VolumeSliderScript = preload("res://scripts/ui/components/volume_slider.gd") + func before_test() -> void: - ## Per-test setup: Instantiate slider, reset state. - ## - ## :rtype: void - # 1. Reset state BEFORE triggering _ready() AudioManager.master_volume = 1.0 AudioManager.apply_all_volumes() # Ensure AudioServer matches the manager - # 2. Instantiate and add to tree - slider = auto_free(VolumeSlider.new()) - slider.bus_name = "Master" # Test with Master + # 2. Instantiate safely using the preloaded script resource + slider = auto_free(VolumeSliderScript.new()) + + # Use the constant instead of a hardcoded string to prevent typos + slider.bus_name = AudioConstants.BUS_MASTER + + # FIX: Replicate the Inspector settings to prevent float snapping! + slider.max_value = 1.0 + slider.step = 0.001 + add_child(slider) # Trigger _ready func after_test() -> void: - ## Cleanup: Reset volume to avoid pollution. - ## - ## :rtype: void AudioManager.master_volume = 1.0 AudioManager.apply_all_volumes() func test_ready_sets_value_and_timer() -> void: - ## Tests _ready gets index, sets value, connects, creates timer. - ## - ## :rtype: void - assert_int(slider.bus_index).is_equal(AudioServer.get_bus_index("Master")) + assert_int(slider.bus_index).is_equal(AudioServer.get_bus_index(AudioConstants.BUS_MASTER)) assert_float(slider.value).is_equal(db_to_linear(AudioServer.get_bus_volume_db(slider.bus_index))) assert_bool(slider.value_changed.is_connected(slider._on_value_changed)).is_true() assert_object(slider.save_debounce_timer).is_not_null() @@ -47,12 +47,11 @@ func test_ready_sets_value_and_timer() -> void: func test_value_changed_updates_volume_and_starts_timer() -> void: - ## Tests value change sets db, updates manager, starts timer. - ## - ## :rtype: void var test_value: float = 0.5 slider._on_value_changed(test_value) assert_float(AudioServer.get_bus_volume_db(slider.bus_index)).is_equal_approx(linear_to_db(test_value), 0.0001) assert_float(AudioManager.master_volume).is_equal(test_value) - assert_bool(not slider.save_debounce_timer.is_stopped()).is_true() # Started + + # Cleaner assertion for the timer state + assert_bool(slider.save_debounce_timer.is_stopped()).is_false() diff --git a/test/gut/test_audio_sync_decoupling.gd b/test/gut/test_audio_sync_decoupling.gd new file mode 100644 index 000000000..7426f69d1 --- /dev/null +++ b/test/gut/test_audio_sync_decoupling.gd @@ -0,0 +1,102 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_audio_sync_decoupling.gd +## +## TEST SUITE: Verifies Signal Decoupling for Web and UI Sync (Issue #567). +## Ensures that programmatic volume updates from the Web Bridge or AudioManager +## do not trigger the slider's value_changed signal, preventing audio feedback +## loops and redundant disk I/O. + +extends "res://addons/gut/test.gd" + +var audio_scene: PackedScene = load(GamePaths.AUDIO_SETTINGS_SCENE) +var audio_instance: Control +var test_config_path: String = "user://test_audio_sync.cfg" + +# State snapshot variables to prevent cross-suite leakage +var _orig_config_path: String +var _orig_master_volume: float +var _orig_sfx_volume: float + + +## Per-test setup: Instantiate audio scene, snapshot singleton, and reset state +## :rtype: void +func before_each() -> void: + # Capture original AudioManager state + _orig_config_path = AudioManager.current_config_path + _orig_master_volume = AudioManager.master_volume + _orig_sfx_volume = AudioManager.sfx_volume + + if FileAccess.file_exists(test_config_path): + DirAccess.remove_absolute(test_config_path) + + # Apply isolated test state + AudioManager.current_config_path = test_config_path + AudioManager.master_volume = 1.0 + AudioManager.sfx_volume = 1.0 + + audio_instance = audio_scene.instantiate() as Control + add_child_autofree(audio_instance) + + +## Per-test cleanup: Free audio_instance safely and restore singleton state. +## :rtype: void +func after_each() -> void: + if is_instance_valid(audio_instance): + if is_instance_valid(audio_instance.master_warning_dialog): + audio_instance.master_warning_dialog.hide() + if is_instance_valid(audio_instance.sfx_warning_dialog): + audio_instance.sfx_warning_dialog.hide() + remove_child(audio_instance) + audio_instance.queue_free() + + if FileAccess.file_exists(test_config_path): + DirAccess.remove_absolute(test_config_path) + + # Restore original AudioManager state to prevent leakage + AudioManager.current_config_path = _orig_config_path + AudioManager.master_volume = _orig_master_volume + AudioManager.sfx_volume = _orig_sfx_volume + + await get_tree().process_frame + + +## Verifies that global volume changes (e.g., from Web Bridge) update the UI +## without firing the value_changed signal. +## :rtype: void +func test_global_volume_changed_bypasses_signals() -> void: + # Precondition: Ensure the timer is stopped + assert_true(audio_instance.master_slider.save_debounce_timer.is_stopped(), "Timer should be stopped initially.") + + # Act: Simulate an incoming Web Bridge sync event + var new_volume: float = 0.35 + audio_instance._on_global_volume_changed(AudioConstants.BUS_MASTER, new_volume) + + # Assert: The slider visually updated, but the timer (and thus SFX) was NOT triggered + assert_eq(audio_instance.master_slider.value, new_volume, "Slider value should reflect the global change.") + assert_true( + audio_instance.master_slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped. If it started, set_value_no_signal() was not used, risking an audio feedback loop." + ) + + +## Verifies that syncing the UI from the AudioManager (e.g., during Reset) +## updates all sliders without firing their signals. +## :rtype: void +func test_sync_ui_from_manager_bypasses_signals() -> void: + # Precondition: Ensure the timer is stopped + assert_true(audio_instance.sfx_slider.save_debounce_timer.is_stopped(), "Timer should be stopped initially.") + + # Setup: Change the backend AudioManager state silently + var new_sfx_volume: float = 0.8 + AudioManager.sfx_volume = new_sfx_volume + + # Act: Force the UI to pull the latest state + audio_instance._sync_ui_from_manager() + + # Assert: The slider updated, but no signals were emitted + assert_eq(audio_instance.sfx_slider.value, new_sfx_volume, "SFX Slider should sync to the new AudioManager value.") + assert_true( + audio_instance.sfx_slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped during a full UI sync. Ensures no initialization sound storms." + ) diff --git a/test/gut/test_audio_sync_decoupling.gd.uid b/test/gut/test_audio_sync_decoupling.gd.uid new file mode 100644 index 000000000..fe3dea009 --- /dev/null +++ b/test/gut/test_audio_sync_decoupling.gd.uid @@ -0,0 +1 @@ +uid://esn13vgqofel diff --git a/test/gut/test_audio_web_bridge_dom_sync.gd b/test/gut/test_audio_web_bridge_dom_sync.gd new file mode 100644 index 000000000..b17cb1e66 --- /dev/null +++ b/test/gut/test_audio_web_bridge_dom_sync.gd @@ -0,0 +1,133 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_audio_web_bridge_dom_sync.gd +## +## TEST SUITE: Verifies DOM Sync Decoupling for Web Bridge (Issue #567). +## This suite proves that Godot state changes execute strictly one-way JavaScript +## DOM updates without emitting signals back into the engine. +## This is a critical safety test to prevent infinite feedback loops. + +extends "res://addons/gut/test.gd" + +# ========================================== +# MOCKS +# ========================================== + +## WHY: Mocks the OS environment to bypass the self-destruction check in +## AudioWebBridge._ready() when running tests in a non-web environment. +class MockOSWrapper extends OSWrapper: + func has_feature(feature: String) -> bool: + return feature == "web" + +## WHY: Mocks the JavaScriptBridge to record the exact strings passed to the browser. +class MockJSBridgeWrapper extends JavaScriptBridgeWrapper: + var eval_calls: Array[String] = [] + # FIX: Use a non-empty dictionary. In GDScript, {} is falsy, + # which caused the bridge to return early. + var mock_window := {"is_mock": true} + + func eval(code: String, _global_exec: bool = false) -> Variant: + eval_calls.append(code) + return null + + func get_interface(interface: String) -> Variant: + if interface == "window": + return mock_window + return null + + func create_callback(_callable: Callable) -> Variant: + return {} + + +# ========================================== +# TESTS +# ========================================== + +var web_bridge: Node +var mock_js: MockJSBridgeWrapper + +## WHY: Prepares the test environment by instantiating the bridge with mocks. +## WHAT: Loads AudioWebBridge using GamePaths and injects dependencies. +## EXPECTED: The script loads and is initialized for isolated evaluation. +func before_each() -> void: + var path: String = GamePaths.AUDIO_WEB_BRIDGE + var bridge_script: Script = load(path) + + if bridge_script == null: + fail_test("Failed to load AudioWebBridge script at: " + path) + return + + web_bridge = bridge_script.new() + + # Injection must happen BEFORE add_child so _ready() uses the mocks + mock_js = MockJSBridgeWrapper.new() + web_bridge.js_bridge_wrapper = mock_js + web_bridge.os_wrapper = MockOSWrapper.new() + + add_child_autoqfree(web_bridge) + + +## WHY: Ensures volume changes in Godot update the HTML DOM via raw property assignment. +## WHAT: Simulates a volume change signal from the AudioManager for the Master bus. +## EXPECTED: The bridge generates a JS string setting the '.value' property. This +## bypasses browser events to prevent Godot from receiving its own changes back. +func test_dom_volume_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Directly trigger the bridge's internal signal listener + var test_volume: float = 0.85 + web_bridge._on_godot_volume_changed(AudioConstants.BUS_MASTER, test_volume) + + # Assert: Verify exactly one JS command was issued + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('master-slider').value = 0.85" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must update HTML DOM directly to prevent feedback loops." + ) + + +## WHY: Ensures that Godot's 'muted' state is correctly inverted for the DOM. +## WHAT: Simulates Godot muting the Music bus (muted = true). +## EXPECTED: DOM state corresponds to 'checked = false' translated via property update. +## Direct assignment prevents the browser from firing an 'onchange' event. +func test_dom_mute_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Broadcast a mute action from Godot + web_bridge._on_godot_mute_toggled(AudioConstants.BUS_MUSIC, true) + + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('mute-music').checked = false" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must directly uncheck the HTML element without Godot signals." + ) + + +## WHY: Ensures that Godot's 'unmuted' state is correctly reflected in the DOM. +## WHAT: Simulates Godot unmuting the SFX bus (muted = false). +## EXPECTED: The bridge translates this to 'checked = true' in JavaScript. +## Property assignment ensures the browser shell remains in sync with the engine. +func test_dom_unmute_sync_executes_js_only() -> void: + mock_js.eval_calls.clear() + + # Act: Broadcast an unmute action from Godot + web_bridge._on_godot_mute_toggled(AudioConstants.BUS_SFX, false) + + assert_eq(mock_js.eval_calls.size(), 1, "Only one DOM update should be triggered.") + + if mock_js.eval_calls.size() > 0: + var expected_js: String = "document.getElementById('mute-sfx').checked = true" + assert_eq( + mock_js.eval_calls[0], + expected_js, + "Bridge must directly check the HTML element without Godot signals." + ) + diff --git a/test/gut/test_audio_web_bridge_dom_sync.gd.uid b/test/gut/test_audio_web_bridge_dom_sync.gd.uid new file mode 100644 index 000000000..f746f80da --- /dev/null +++ b/test/gut/test_audio_web_bridge_dom_sync.gd.uid @@ -0,0 +1 @@ +uid://bxeniyxr24v2t diff --git a/test/gut/test_volume_slider.gd b/test/gut/test_volume_slider.gd new file mode 100644 index 000000000..76219054c --- /dev/null +++ b/test/gut/test_volume_slider.gd @@ -0,0 +1,252 @@ +## Copyright (C) 2026 Egor Kostan +## SPDX-License-Identifier: GPL-3.0-or-later +## test_volume_slider.gd +## +## TEST SUITE: Verifies the isolated logic of the VolumeSlider component. +## Covers initialization, programmatic update guards, and SFX interaction/rate-limiting guards. + +extends "res://addons/gut/test.gd" + +# ========================================== +# MOCKS +# ========================================== + +class MockAudioManager extends Node: + var played_sfx: Array[String] = [] + + func play_sfx(sfx_name: String, _bus_name: String = "", _pitch_scale: float = 1.0, _volume_db: float = 0.0) -> void: + played_sfx.append(sfx_name) + +# ========================================== +# TESTS +# ========================================== + +var _slider: VolumeSlider + +# Snapshot variables for state isolation +var _orig_config_path: String +var _orig_master_volume: float +const _TEST_CONFIG_PATH: String = "user://test_volume_slider.cfg" + + +func before_each() -> void: + # Snapshot global state to prevent cross-suite leakage + _orig_config_path = AudioManager.current_config_path + _orig_master_volume = AudioManager.master_volume + + # Isolate the config path so any rogue debounce saves hit a throwaway file + AudioManager.current_config_path = _TEST_CONFIG_PATH + + _slider = VolumeSlider.new() + _slider.bus_name = AudioConstants.BUS_MASTER + + # FIX: Replicate the Inspector settings for a volume slider + # Without this, HSlider defaults to step=1.0 and snaps all floats! + _slider.max_value = 1.0 + _slider.step = 0.001 + + # Add to the tree to ensure _ready() fires and UI state works + add_child_autoqfree(_slider) + + +func after_each() -> void: + # Restore global state + AudioManager.current_config_path = _orig_config_path + AudioManager.master_volume = _orig_master_volume + + # Clean up any test config generated if the debounce timer fired during CI lag + if FileAccess.file_exists(_TEST_CONFIG_PATH): + DirAccess.remove_absolute(_TEST_CONFIG_PATH) + + +# ========================================== +# INITIALIZATION & PROGRAMMATIC GUARDS +# ========================================== + +## WHY: Verifies that the component starts in a clean, predictable state. +## WHAT: Checks if the debounce timer is instantiated but not active. +## EXPECTED: Timer is not null and is currently stopped. +func test_initialization() -> void: + assert_not_null(_slider.save_debounce_timer, "Debounce timer should be created on _ready") + assert_true(_slider.save_debounce_timer.is_stopped(), "Timer should not be running immediately after initialization") + + +## WHY: Proves that programmatic updates (e.g., from Web Bridge or Init) are decoupled. +## WHAT: Updates the slider value using the set_value_programmatically() helper. +## EXPECTED: The value reflects the update, but the save timer remains stopped to prevent disk I/O spam. +func test_programmatic_change_blocks_debounce_timer() -> void: + _slider.set_value_programmatically(0.5) + + assert_eq(_slider.value, 0.5, "Slider value should reflect the programmatic update") + assert_true( + _slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST remain stopped during programmatic changes to prevent disk I/O spam" + ) + + +## WHY: Ensures that intentional user interaction correctly schedules a save operation. +## WHAT: Directly modifies the slider 'value' property to simulate a manual change event. +## EXPECTED: The save_debounce_timer is started to handle the persistence. +func test_manual_value_change_starts_debounce_timer() -> void: + # Simulate a standard UI value change + _slider.value = 0.8 + + assert_false( + _slider.save_debounce_timer.is_stopped(), + "Debounce timer MUST start when a value is changed manually" + ) + + +## WHY: Confirms that non-interactive updates do not inadvertently flip interaction flags. +## WHAT: Performs a programmatic update and checks the internal _is_dragging state. +## EXPECTED: The _is_dragging flag remains false. +func test_programmatic_change_does_not_alter_drag_state() -> void: + _slider.set_value_programmatically(0.2) + assert_false(_slider.is_user_dragging(), "Programmatic changes should not affect the _is_dragging state") + + +# ========================================== +# SFX UX GUARDS +# ========================================== + +## WHY: Prevents audio spam when a slider event fires without a meaningful value change. +## WHAT: Attempts to trigger SFX logic using a value identical to the previous state. +## EXPECTED: Guard 1 blocks the playback; _last_sfx_time is not updated. +func test_sfx_guard_blocks_identical_values() -> void: + # Setup: Set an initial value and simulate an interaction + _slider.value = 0.5 + _slider._previous_value = 0.5 + _slider._is_dragging = true + var initial_sfx_time: int = _slider.get_last_sfx_time() + + # Act: Try to trigger the full pipeline with the exact same value + _slider._on_value_changed(0.5) + + # Assert: The time shouldn't update because the early return blocked it + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the value hasn't actually changed.") + + +## WHY: Validates the "Happy Path" for manual interaction audio feedback. +## WHAT: Simulates a manual drag interaction accompanied by a value delta. +## EXPECTED: All guards pass; _last_sfx_time is updated and _previous_value is committed. +func test_sfx_guard_allows_valid_interaction() -> void: + # 1. Setup the manual mock to block real audio I/O + var mock_am: MockAudioManager = MockAudioManager.new() + + # Swap out the real AudioManager in the scene tree + var root := get_tree().root + var real_am := root.get_node("AudioManager") + root.remove_child(real_am) + root.add_child(mock_am) + mock_am.name = "AudioManager" + + # 2. Setup slider interaction variables + _slider._previous_value = 0.2 + _slider._is_dragging = true + _slider._last_sfx_time = 0 # Ensure no cooldown interference + + # 3. Act: Trigger the full pipeline + _slider._on_value_changed(0.5) + + # 4. Assert local state + assert_ne(_slider.get_last_sfx_time(), 0, "SFX time should update when a valid, manual value delta occurs.") + assert_eq(_slider.get_previous_value(), 0.5, "Previous value should be updated after successful SFX trigger.") + + # 5. Assert the mock received the play_sfx call, proving the guards passed + assert_eq(mock_am.played_sfx.size(), 1, "play_sfx should be called exactly once.") + assert_eq(mock_am.played_sfx[0], AudioConstants.SFX_SLIDER, "The correct SFX constant should be played.") + + # 6. Cleanup: Safely restore the original AudioManager + root.remove_child(mock_am) + root.add_child(real_am) + mock_am.free() + + +## WHY: Restricts SFX playback strictly to active user engagement. +## WHAT: Changes the value while the slider is neither being dragged nor focused. +## EXPECTED: Guard 2 blocks playback; _last_sfx_time remains at its initial value. +func test_sfx_guard_blocks_no_interaction() -> void: + # Setup: New value, but NO interaction (not dragging, no focus) + _slider.value = 0.5 + _slider._previous_value = 0.2 + _slider._is_dragging = false + _slider.release_focus() + var initial_sfx_time: int = _slider.get_last_sfx_time() + + # Act: Try to trigger SFX + _slider._handle_slider_sfx(0.5) + + # Assert: The time shouldn't update because Guard 2 blocked it + assert_eq(_slider.get_last_sfx_time(), initial_sfx_time, "SFX must be blocked if the user isn't actively interacting.") + + +## WHY: Protects the user from ear-piercing noise during rapid mouse movements. +## WHAT: Attempts to trigger a second SFX trigger immediately after a successful one. +## EXPECTED: Guard 3 (Rate Limiter) blocks the second trigger based on SFX_COOLDOWN_MS. +func test_sfx_guard_enforces_rate_limiting() -> void: + # Setup: Valid interaction, but we JUST played a sound + _slider._previous_value = 0.2 + _slider._is_dragging = true + + # Force the last sfx time into the future to guarantee a deterministic block + # regardless of CI thread pauses or garbage collection spikes. + var future_time: int = Time.get_ticks_msec() + _slider.SFX_COOLDOWN_MS + 1000 + _slider._last_sfx_time = future_time + + # Act: Try to trigger another sound immediately with a new value + _slider._handle_slider_sfx(0.6) + + # Assert: It should have been blocked by the SFX_COOLDOWN_MS guard + assert_eq( + _slider.get_last_sfx_time(), + future_time, + "Rate limiter MUST block sounds requested faster than the cooldown window." + ) + + +# ========================================== +# INVALID BUS GUARDS (Bug Risk) +# ========================================== + +## WHY: Ensures the game doesn't crash and fully locks out inputs if an audio bus name is typoed. +## WHAT: Initializes a new slider with a fake bus name. +## EXPECTED: The slider detects the -1 index, logs the error to Globals, disables itself, drops focus/mouse handling, and aborts safely. +func test_invalid_bus_disables_slider() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "NonExistentBus123" + + # Add to tree to trigger _ready() + add_child_autoqfree(bad_slider) + + assert_false(bad_slider.editable, "Slider must disable itself if the audio bus is invalid.") + assert_eq(bad_slider.mouse_filter, Control.MOUSE_FILTER_IGNORE, "Slider must ignore mouse events if invalid.") + assert_eq(bad_slider.focus_mode, Control.FOCUS_NONE, "Slider must drop keyboard/controller focus if invalid.") + assert_null(bad_slider.save_debounce_timer, "Initialization should abort early, leaving the timer null.") + + +## WHY: Prevents external scripts from forcing updates on a broken slider. +## WHAT: Attempts to programmatically set the value of an invalid slider. +## EXPECTED: The guard clause blocks the update, leaving state trackers at their default values. +func test_invalid_bus_blocks_programmatic_updates() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "AnotherFakeBus" + add_child_autoqfree(bad_slider) + + # Act: Try to force a value update + bad_slider.set_value_programmatically(0.8) + + # Assert: The values should remain at their uninitialized defaults + assert_eq(bad_slider._previous_value, -1.0, "The delta tracker should not update for an invalid bus.") + assert_eq(bad_slider.value, 0.0, "The visual slider value should not update for an invalid bus.") + + +## WHY: Guarantees no SFX or volume updates can occur from user interaction on a dead slider. +## WHAT: Verifies the signal connections are bypassed during a failed initialization. +## EXPECTED: value_changed and gui_input signals are never connected to their respective handlers. +func test_invalid_bus_prevents_signal_connections() -> void: + var bad_slider: VolumeSlider = VolumeSlider.new() + bad_slider.bus_name = "GhostBus" + add_child_autoqfree(bad_slider) + + assert_false(bad_slider.value_changed.is_connected(bad_slider._on_value_changed), "Value changed signal must remain disconnected.") + assert_false(bad_slider.gui_input.is_connected(bad_slider._on_gui_input), "GUI input signal must remain disconnected.") diff --git a/test/gut/test_volume_slider.gd.uid b/test/gut/test_volume_slider.gd.uid new file mode 100644 index 000000000..6be536555 --- /dev/null +++ b/test/gut/test_volume_slider.gd.uid @@ -0,0 +1 @@ +uid://33pfsdmk7jbg