Fix 354 gdlint warnings across 65 files: 194 class-definitions-order (reorder declarations), 138 max-line-length (split long lines), 22 code issues (unused args, no-else-return, naming). Update .gdlintrc to exclude addons/ and raise max-public-methods for test files. No logic changes — declaration order, whitespace, and naming only. Ticket: #783 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
358 lines
17 KiB
GDScript
358 lines
17 KiB
GDScript
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
|
||
## Spec refs:
|
||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
|
||
## D-069: Audio dip profiles — confrontation drops WorldSFX 4–6dB
|
||
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
|
||
##
|
||
## Test layers:
|
||
## 1. D-068 Bus architecture constants (no implementation required)
|
||
## 2. D-069 Dip profile spec values (no implementation required)
|
||
## 3. AudioManager API — dip state machine, signal emission
|
||
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
|
||
class_name TestAudioBusRouting
|
||
extends GdUnitTestSuite
|
||
|
||
|
||
func before_test() -> void:
|
||
AudioManager.clear_dip()
|
||
for bus in AudioManager.BUSES:
|
||
AudioManager.set_volume(bus, 0.0)
|
||
GameState.stationary_ticks = 0
|
||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||
|
||
|
||
func after_test() -> void:
|
||
AudioManager.clear_dip()
|
||
GameState.stationary_ticks = 0
|
||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 1: D-068 Bus Architecture Constants
|
||
# ==============================================================================
|
||
|
||
func test_d068_bus_world_sfx_name() -> void:
|
||
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
|
||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||
|
||
func test_d068_bus_ambient_name() -> void:
|
||
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
|
||
|
||
func test_d068_bus_player_actions_name() -> void:
|
||
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
|
||
|
||
func test_d068_bus_ui_sounds_name() -> void:
|
||
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
|
||
|
||
func test_d068_bus_music_name() -> void:
|
||
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
|
||
|
||
func test_d068_five_buses_defined() -> void:
|
||
## D-068: Exactly 5 buses in the architecture.
|
||
assert_that(AudioManager.BUSES.size()).is_equal(5)
|
||
|
||
func test_d068_all_bus_names_present_in_buses_array() -> void:
|
||
## D-068: BUSES array must contain all 5 named buses.
|
||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
|
||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
|
||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
|
||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
|
||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
|
||
|
||
func test_d068_bus_names_are_unique() -> void:
|
||
## D-068: No two buses share a name.
|
||
var seen: Dictionary = {}
|
||
for bus_name in AudioManager.BUSES:
|
||
assert_that(seen.has(bus_name)).is_false()
|
||
seen[bus_name] = true
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 2: D-069 Dip Profile Spec Values
|
||
# Close-range sound events go on WorldSFX; confrontation dips that bus 4–6 dB.
|
||
# ==============================================================================
|
||
|
||
func test_d069_three_dip_profiles_exist() -> void:
|
||
## D-069: Three profiles — dialogue, confrontation, listening_focus.
|
||
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
|
||
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
|
||
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
|
||
|
||
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
|
||
## D-069: Confrontation must drop WorldSFX by 4–6 dB. Sprint 12 uses -5.0 dB.
|
||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||
assert_that(buses.has("WorldSFX")).is_true()
|
||
var dip_db: float = buses["WorldSFX"]
|
||
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
|
||
|
||
func test_d069_confrontation_dips_ambient() -> void:
|
||
## D-069: Confrontation suppresses both Ambient and WorldSFX.
|
||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||
assert_that(buses.has("Ambient")).is_true()
|
||
|
||
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
|
||
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
|
||
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
|
||
assert_that(spec.has("filter_hz")).is_true()
|
||
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
|
||
|
||
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
|
||
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
|
||
## Confrontation is the one that mutes world SFX.
|
||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||
assert_that(buses.has("Ambient")).is_true()
|
||
assert_that(buses.has("WorldSFX")).is_false()
|
||
|
||
func test_d069_listening_focus_boosts_world_sfx() -> void:
|
||
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
|
||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||
assert_that(buses.has("WorldSFX")).is_true()
|
||
assert_that(buses["WorldSFX"] > 0.0).is_true()
|
||
|
||
func test_d069_all_profiles_have_positive_ease_times() -> void:
|
||
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
|
||
for profile in AudioManager.DIP_SPECS.keys():
|
||
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
|
||
assert_that(spec.has("ease_in")).is_true()
|
||
assert_that(spec.has("ease_out")).is_true()
|
||
assert_that(spec["ease_in"] > 0.0).is_true()
|
||
assert_that(spec["ease_out"] > 0.0).is_true()
|
||
|
||
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
|
||
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
|
||
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||
assert_that(conf_out >= dial_out).is_true()
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 2b: D-067 Recognition Chime
|
||
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
|
||
# ==============================================================================
|
||
|
||
func test_d067_chime_recognition_constant_exists() -> void:
|
||
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
|
||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||
|
||
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
|
||
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
|
||
## "Neural lattice firing" feel — soft crystalline tone.
|
||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||
|
||
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
|
||
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
|
||
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
|
||
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
|
||
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
|
||
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
|
||
## No further assertion needed — play() default bus IS UISounds by design.
|
||
|
||
func test_d067_chime_noop_when_asset_absent() -> void:
|
||
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
|
||
## play(CHIME_RECOGNITION) must be a no-op (no crash).
|
||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||
## No assertion — absence of crash is the test.
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 3: AudioManager API — Dip State Machine
|
||
# ==============================================================================
|
||
|
||
func test_audio_manager_initial_state_no_active_dip() -> void:
|
||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||
|
||
func test_audio_manager_apply_dip_sets_active() -> void:
|
||
AudioManager.apply_dip("dialogue")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||
|
||
func test_audio_manager_clear_dip_resets_active() -> void:
|
||
AudioManager.apply_dip("confrontation")
|
||
AudioManager.clear_dip()
|
||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||
|
||
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
|
||
## Unknown profile: push_warning + early return, active dip stays empty.
|
||
AudioManager.apply_dip("not_a_real_profile")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||
|
||
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
|
||
## apply_dip() must emit dip_changed(profile) synchronously.
|
||
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
|
||
## so a mutable reference type is required to observe signal argument inside the closure.
|
||
var received := [""]
|
||
var conn := func(p: String) -> void: received[0] = p
|
||
AudioManager.dip_changed.connect(conn)
|
||
AudioManager.apply_dip("dialogue")
|
||
AudioManager.dip_changed.disconnect(conn)
|
||
assert_that(received[0]).is_equal("dialogue")
|
||
|
||
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
|
||
## clear_dip() must emit dip_changed("") to signal audio restored.
|
||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||
AudioManager.apply_dip("dialogue")
|
||
var received := ["sentinel"]
|
||
var conn := func(p: String) -> void: received[0] = p
|
||
AudioManager.dip_changed.connect(conn)
|
||
AudioManager.clear_dip()
|
||
AudioManager.dip_changed.disconnect(conn)
|
||
assert_that(received[0]).is_equal("")
|
||
|
||
func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||
## Switching profiles mid-dip: active profile must update to the new one.
|
||
AudioManager.apply_dip("dialogue")
|
||
AudioManager.apply_dip("confrontation")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||
|
||
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
|
||
## Switching from dialogue to confrontation emits dip_changed("confrontation").
|
||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||
AudioManager.apply_dip("dialogue")
|
||
var received := [""]
|
||
var conn := func(p: String) -> void: received[0] = p
|
||
AudioManager.dip_changed.connect(conn)
|
||
AudioManager.apply_dip("confrontation")
|
||
AudioManager.dip_changed.disconnect(conn)
|
||
assert_that(received[0]).is_equal("confrontation")
|
||
|
||
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
|
||
## has_asset() must return false for a key that was never registered.
|
||
## Ensures no-op fallback path (D-038) is correctly guarded.
|
||
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 4: D-018 Snapshot Integration — #125 implemented
|
||
# GameState partitions sound_events by range_category into close_sound_events
|
||
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
|
||
# ==============================================================================
|
||
|
||
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
|
||
## #125: Close-range events must land in GameState.close_sound_events.
|
||
GameState.apply_snapshot({
|
||
"tick": 1,
|
||
"sound_events": [
|
||
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
|
||
]
|
||
})
|
||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
|
||
|
||
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
|
||
## #126/#125: Medium-range events land in medium_sound_events, not close.
|
||
GameState.apply_snapshot({
|
||
"tick": 1,
|
||
"sound_events": [
|
||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
|
||
]
|
||
})
|
||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||
|
||
func test_snapshot_partitions_close_and_medium_events() -> void:
|
||
## D-018: Mixed batch — Close and Medium events partitioned correctly.
|
||
GameState.apply_snapshot({
|
||
"tick": 1,
|
||
"sound_events": [
|
||
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
|
||
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
|
||
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
|
||
]
|
||
})
|
||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||
|
||
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
|
||
## When snapshot has no sound_events, both close and medium arrays are cleared.
|
||
GameState.apply_snapshot({
|
||
"tick": 1,
|
||
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
|
||
})
|
||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||
GameState.apply_snapshot({"tick": 2})
|
||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||
|
||
# ==============================================================================
|
||
# Layer 5: AudioManager.play_sound_event — asset registry and routing
|
||
# ==============================================================================
|
||
|
||
func test_audio_manager_sound_event_assets_registry_exists() -> void:
|
||
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
|
||
|
||
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
|
||
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
|
||
|
||
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
|
||
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
|
||
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
|
||
|
||
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
|
||
## #125 / D-038: Unknown event types must be silently skipped (no-op).
|
||
## play_sound_event should not crash and should not emit sound.
|
||
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
|
||
# No assertion needed — absence of crash is the test.
|
||
|
||
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
|
||
## #125: Empty event type string must be a no-op.
|
||
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
|
||
# No assertion needed — absence of crash is the test.
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
|
||
# ==============================================================================
|
||
|
||
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
|
||
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
|
||
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
|
||
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
|
||
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
|
||
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
|
||
|
||
func test_d071_stationary_ticks_resets_on_movement() -> void:
|
||
## D-071: stationary_ticks must reset to 0 when the player position changes.
|
||
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
|
||
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
|
||
assert_that(GameState.stationary_ticks).is_equal(1)
|
||
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
|
||
assert_that(GameState.stationary_ticks).is_equal(0)
|
||
|
||
func test_d071_stationary_ticks_reaches_threshold() -> void:
|
||
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
|
||
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||
for tick in range(31):
|
||
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
|
||
assert_that(GameState.stationary_ticks >= 30).is_true()
|
||
|
||
|
||
# ==============================================================================
|
||
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
|
||
# ==============================================================================
|
||
|
||
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
|
||
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
|
||
AudioManager.apply_dip("dialogue")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||
AudioManager.apply_dip("confrontation")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||
|
||
func test_d069_listening_focus_cleared_by_dialogue() -> void:
|
||
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
|
||
AudioManager.apply_dip("listening_focus")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
|
||
AudioManager.apply_dip("dialogue")
|
||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||
|
||
func test_d069_clear_dip_noop_when_empty() -> void:
|
||
## D-069: clear_dip() when no dip active must be a safe no-op.
|
||
AudioManager.clear_dip() # Should not crash
|
||
assert_that(AudioManager.get_active_dip()).is_equal("")
|