diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd index f0df0eb2b..8dda3e962 100644 --- a/client/tests/test_audio_bus_routing.gd +++ b/client/tests/test_audio_bus_routing.gd @@ -15,10 +15,16 @@ extends GdUnitTestSuite func before_test() -> void: AudioManager.clear_dip() + for bus in AudioManager.BUSES: + AudioManager.set_volume(bus, 0.0) + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) func after_test() -> void: AudioManager.clear_dip() + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) # ============================================================================== @@ -171,22 +177,25 @@ func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void: func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void: ## apply_dip() must emit dip_changed(profile) synchronously. - var received_profile := "" - var conn := func(p: String) -> void: received_profile = p + ## 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_profile).is_equal("dialogue") + 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_profile := "sentinel" - var conn := func(p: String) -> void: received_profile = p + 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_profile).is_equal("") + 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. @@ -196,13 +205,14 @@ func test_audio_manager_apply_dip_interrupts_previous() -> void: 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_profile := "" - var conn := func(p: String) -> void: received_profile = p + 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_profile).is_equal("confrontation") + 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. @@ -291,3 +301,57 @@ 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("") diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd new file mode 100644 index 000000000..f8a3cc79d --- /dev/null +++ b/client/tests/test_audio_sprint13.gd @@ -0,0 +1,560 @@ +## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073). +## +## Spec refs: +## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion. +## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds). +## D-069: Audio dip profiles — timing values, dB offsets, interruptibility. +## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility). +## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants. +## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible. +## +## Tickets covered: +## #529 — Zone crossfade implementation (set_zone body + tween timing) +## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring) +## #531 — Recognition chime fires at cognitive delay onset +## #533 — NPC conversation murmur wired to WorldSFX bus +## +## Test layers: +## 1. Zone crossfade spec and API (D-073 / #529) +## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing) +## 3. Volume slider proportional dip (D-068 / D-069) +## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530) +## 5. Recognition chime onset verification (D-067 / #531) +## 6. NPC murmur routing (D-072 / #533) +## 7. AudioManager no-op fallback sanity (D-068 / D-038) +class_name TestAudioSprint13 +extends GdUnitTestSuite + + +func before_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0) + AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0) + + +func after_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + + +# ============================================================================== +# Layer 1: Zone Crossfade — D-073 / #529 +# set_zone() stub exists now; full implementation lands in #529. +# ============================================================================== + +func test_d073_set_zone_method_exists() -> void: + ## D-073 / #529: AudioManager must expose set_zone(zone_id: String). + assert_that(AudioManager.has_method("set_zone")).is_true() + + +func test_d073_set_zone_hub_does_not_crash() -> void: + ## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation. + AudioManager.set_zone("hub") + + +func test_d073_set_zone_bar_does_not_crash() -> void: + ## D-073: set_zone("bar") is a safe call. + AudioManager.set_zone("bar") + + +func test_d073_set_zone_corridor_does_not_crash() -> void: + ## D-073: set_zone("corridor") is a safe call. + AudioManager.set_zone("corridor") + + +func test_d073_set_zone_empty_string_does_not_crash() -> void: + ## D-073: set_zone("") edge case — no zone ID. Must not crash. + AudioManager.set_zone("") + + +func test_d073_set_zone_unknown_zone_does_not_crash() -> void: + ## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op. + AudioManager.set_zone("nonexistent_zone_xyz") + + +func test_d073_zone_asset_hub_key_matches_filename_convention() -> void: + ## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief. + ## hub/workplace → amb_hub_layer (must match filename stem in res://audio/). + ## Test verifies naming convention is documentable. Activates once #529 adds the map. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("hub")).is_true() + assert_that(zone_assets["hub"]).is_equal("amb_hub_layer") + + +func test_d073_zone_asset_bar_key_matches_filename_convention() -> void: + ## D-073 / #529: bar → amb_bar_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("bar")).is_true() + assert_that(zone_assets["bar"]).is_equal("amb_bar_layer") + + +func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void: + ## D-073 / #529: smuggling corridor → amb_corridor_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("corridor")).is_true() + assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer") + + +func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void: + ## D-073: Crossfade tween duration must be 1.5-2.0s. + ## Activates once #529 defines the duration constant. + if not "CROSSFADE_DURATION" in AudioManager: + push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test") + return + var duration: float = AudioManager.CROSSFADE_DURATION + assert_that(duration >= 1.5 and duration <= 2.0).is_true() + + +func test_d073_set_zone_same_zone_repeated_is_noop() -> void: + ## D-073: Crossing back to the current zone should not restart a crossfade. + ## (No audio pops when zone boundary is ambiguous.) Activates post-#529. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test") + return + AudioManager.set_zone("hub") + AudioManager.set_zone("hub") + ## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens). + ## Stub: assert no crash and ambient_players size is 0 or 1, not 2. + assert_that(AudioManager._ambient_players.size() <= 1).is_true() + + +# ============================================================================== +# Layer 2: D-069 Dip Timing and dB Spec Values +# These tests supplement test_audio_bus_routing.gd with timing and dB precision. +# ============================================================================== + +func test_d069_dialogue_ease_in_is_300ms() -> void: + ## D-069: Dialogue dip ease-in = 300ms (0.3s). + var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.3, 0.05) + + +func test_d069_dialogue_ease_out_is_500ms() -> void: + ## D-069: Dialogue dip ease-out = 500ms (0.5s). + var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"] + assert_that(ease_out).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_in_is_500ms() -> void: + ## D-069: Confrontation dip ease-in = 500ms (0.5s). + var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_out_is_1000ms() -> void: + ## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion. + var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"] + assert_that(ease_out).is_equal_approx(1.0, 0.05) + + +func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void: + ## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used. + var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"] + assert_that(dip >= -8.0 and dip <= -6.0).is_true() + + +func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void: + ## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used. + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"] + assert_that(dip >= -12.0 and dip <= -10.0).is_true() + + +func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void: + ## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through). + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"] + assert_that(dip >= -6.0 and dip <= -4.0).is_true() + + +func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void: + ## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus). + var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"] + assert_that(boost >= 2.0 and boost <= 3.0).is_true() + + +func test_d069_dialogue_spec_only_affects_ambient_bus() -> void: + ## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music + ## must NOT appear in the spec — world events remain audible during conversation. + var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"] + assert_that(buses.has("Ambient")).is_true() + assert_that(buses.has("WorldSFX")).is_false() + assert_that(buses.has("PlayerActions")).is_false() + assert_that(buses.has("UISounds")).is_false() + assert_that(buses.has("Music")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_player_actions() -> void: + ## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("PlayerActions")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void: + ## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("UISounds")).is_false() + + +func test_d069_listening_focus_spec_does_not_affect_ambient() -> void: + ## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified. + ## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip. + var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"] + assert_that(buses.has("Ambient")).is_false() + + +func test_d069_filter_cutoff_default_is_approx_20khz() -> void: + ## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed. + ## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz. + assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true() + + +func test_d069_confrontation_filter_hz_is_800hz() -> void: + ## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070). + var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"] + assert_that(filter_hz).is_equal_approx(800.0, 50.0) + + +# ============================================================================== +# Layer 3: Volume Slider Proportional Dip — D-068 / D-069 +# ============================================================================== + +func test_d068_set_volume_get_volume_roundtrip() -> void: + ## D-068: set_volume / get_volume roundtrip preserves the slider value. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + + +func test_d068_set_volume_persists_across_all_buses() -> void: + ## D-068: Each of the 5 buses has an independent volume setting. + AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01) + + +func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void: + ## D-069: get_volume() always returns the player slider setting (base). + ## The effective AudioServer volume during a dip = base + offset_db. + ## Callers storing the slider value must always read get_volume(), not AudioServer directly. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + ## Even during dip, get_volume returns the base (not base + dip offset). + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01) + + +func test_d069_set_volume_during_active_dip_updates_base() -> void: + ## D-069: Changing slider mid-dip must update the base so the proportional + ## calculation uses the new slider value (not the pre-dip value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01) + + +func test_d069_clear_dip_does_not_change_stored_slider_value() -> void: + ## D-069: clear_dip() restores AudioServer volumes to base, but get_volume() + ## must still reflect the player slider setting (not the dipped value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.apply_dip("dialogue") + AudioManager.clear_dip() + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + + +# ============================================================================== +# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530 +# +# These tests verify GameState has the snapshot fields needed for #530 wiring. +# The assertions on AudioManager dip activation are stubbed pending implementation. +# ============================================================================== + +func test_d069_game_state_current_dialogue_field_exists() -> void: + ## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip. + ## Field must exist so #530 wiring can check it. + assert_that("current_dialogue" in GameState).is_true() + + +func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void: + ## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": { + "npc_name": "Kael", "npc_entity_id": 2, + "speech": "Haven't seen you around.", "options": [], + }, + }) + assert_that(GameState.current_dialogue != null).is_true() + assert_that(GameState.current_dialogue is Dictionary).is_true() + GameState.apply_snapshot({"tick": 2}) + + +func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void: + ## #530 precondition: Snapshot without current_dialogue → current_dialogue is null. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void: + ## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires. + ## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state. + AudioManager.clear_dip() + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + ## Precondition: dialogue IS active in GameState. + assert_that(GameState.current_dialogue != null).is_true() + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + GameState.apply_snapshot({"tick": 2}) + AudioManager.clear_dip() + + +func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void: + ## D-069 / #530: When current_dialogue returns to null, clear_dip() fires. + ## TODO(#530): Uncomment the assertion once call site is wired. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) # dialogue ends + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("") + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d071_listening_focus_gate_is_30_ticks() -> void: + ## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks. + ## The tick gate is the CALLER's responsibility per AudioManager comment. + ## This test documents the threshold so it doesn't silently drift. + ## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530. + const LISTENING_FOCUS_TICK_GATE := 30 + ## Stub: verify the spec value is documented. + assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30) + + +func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void: + ## D-070: Confrontation muffling is felt, not announced. No UI indicator. + ## Verify AudioManager does not expose a confrontation_ui_shown signal. + var signals: Array = AudioManager.get_signal_list().map( + func(s: Dictionary) -> String: return s.name + ) + assert_that(signals.has("confrontation_ui_shown")).is_false() + assert_that(signals.has("listening_focus_shown")).is_false() + + +# ============================================================================== +# Layer 5: Recognition Chime Onset — D-067 / #531 +# +# Chime fires at ONSET of cognitive delay (when entity FIRST appears in +# pending_recognitions), NOT at completion (when it leaves). +# ============================================================================== + +func test_d067_chime_recognition_constant_defined() -> void: + ## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant. + assert_that("CHIME_RECOGNITION" in AudioManager).is_true() + + +func test_d067_chime_recognition_matches_d038_asset_key() -> void: + ## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel. + ## Key must match filename stem in res://audio/. + assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime") + + +func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void: + ## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default. + ## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX. + ## Verify play() default bus is UISounds (the chime caller uses the default). + ## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct. + assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS) + + +func test_d067_play_chime_recognition_noop_when_asset_absent() -> void: + ## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent. + ## Client must not crash when audio branch has not yet provided the .ogg file. + AudioManager.play(AudioManager.CHIME_RECOGNITION) + ## No assertion needed — absence of crash is the test. + + +func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void: + ## D-067: "Onset" of cognitive delay = first frame an entity appears in + ## pending_recognitions, at remaining_ticks == total_delay_ticks. + ## This is the moment the chime must fire. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + var rec: Dictionary = GameState.pending_recognitions[0] + ## Onset condition: remaining == total (delay just started) + assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks) + GameState.apply_snapshot({"tick": 2}) + + +func test_d067_completion_is_when_entity_absent_from_pending() -> void: + ## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions. + ## The chime must NOT fire at this point — it fired at onset. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 1, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + ## Completion: entity removed from array + GameState.apply_snapshot({"tick": 2, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + ## Chime state: AudioManager must not have a dip triggered by recognition. + ## (Chime is a play() call, not a dip — this verifies no side effects on dip state.) + assert_that(AudioManager.get_active_dip()).is_equal("") + + +func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void: + ## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd + ## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop. + ## This test verifies FogEntities correctly identifies the onset condition. + ## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay. + var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6} + var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 3, "total_delay_ticks": 6} + ## Frame 1: entity APPEARS (onset — chime should fire here) + GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks + == GameState.pending_recognitions[0].total_delay_ticks).is_true() + ## Frame 2: entity mid-progress (chime must NOT re-fire) + GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3) + ## Frame 3: entity completes (chime must NOT fire) + GameState.apply_snapshot({"tick": 3, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + + +func test_d067_chime_duration_spec_is_300_to_400ms() -> void: + ## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration. + ## This test documents the spec range so asset authoring can be validated. + ## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should + ## return a value in this range. + const CHIME_MIN_DURATION := 0.3 + const CHIME_MAX_DURATION := 0.4 + if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION): + push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test") + return + var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION) + if stream == null: + push_warning("TestAudioSprint13: could not retrieve chime stream from registry") + return + assert_that(stream.get_length() >= CHIME_MIN_DURATION + and stream.get_length() <= CHIME_MAX_DURATION).is_true() + + +# ============================================================================== +# Layer 6: NPC Murmur Routing — D-072 / #533 +# Single universal asset, WorldSFX bus, no zone-specific variants. +# ============================================================================== + +func test_d072_world_sfx_bus_is_correct_for_murmur() -> void: + ## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture. + ## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined + ## by that bus's noise floor relative to the murmur volume. + assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX") + + +func test_d072_play_at_method_accepts_bus_parameter() -> void: + ## D-072 / #533: play_at() must accept an optional bus string parameter. + ## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX). + assert_that(AudioManager.has_method("play_at")).is_true() + + +func test_d072_play_at_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532). + ## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op. + AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_play_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully. + AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_no_zone_specific_murmur_variants() -> void: + ## D-072: SINGLE universal murmur asset — no zone-specific variants. + ## "One murmur asset + zone-dependent conspicuousness creates the signal/noise + ## dynamic naturally." Zone-specific variants MUST NOT exist in the registry. + assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false() + + +func test_d072_murmur_does_not_use_ambient_bus() -> void: + ## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background). + ## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient. + ## Verify bus constant distinction. + assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX) + + +# ============================================================================== +# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038 +# ============================================================================== + +func test_d068_registry_size_is_non_negative() -> void: + ## D-068: Registry is empty when res://audio/ is absent, non-negative always. + assert_that(AudioManager.get_registry_size() >= 0).is_true() + + +func test_d068_play_loop_returns_null_for_missing_asset() -> void: + ## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash). + var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc") + assert_that(result == null).is_true() + + +func test_d068_stop_loop_noop_for_unknown_key() -> void: + ## D-068: stop_loop() on a key never started — no crash, no error. + AudioManager.stop_loop("nonexistent_key_xyzabc") + + +func test_d068_stop_all_loops_when_none_playing() -> void: + ## D-068: stop_all_loops() with no active ambient players — no crash. + AudioManager.stop_all_loops() + + +func test_d068_has_asset_returns_false_for_unknown_key() -> void: + ## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key. + assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false() + + +func test_d068_play_noop_does_not_change_dip_state() -> void: + ## D-068: play() on a missing asset must not modify the dip state machine. + ## Verifies no-op fallback has zero side effects. + AudioManager.apply_dip("dialogue") + AudioManager.play("nonexistent_asset_xyzabc") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue")