## 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_game_state_extracts_zone_id_from_player_tile() -> void: ## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as ## a first-class field (like player_facing, player_stance), avoiding O(N) tile ## scan in main.gd per D-020 server-authoritative state. var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} GameState.apply_snapshot({ "tick": 1, "entities": [player], "tiles": [ {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"}, {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"}, ], }) assert_that(GameState.current_zone_id).is_equal("bar") GameState.apply_snapshot({"tick": 2}) func test_d073_game_state_zone_id_empty_when_field_absent() -> void: ## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09). var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} GameState.apply_snapshot({ "tick": 1, "entities": [player], "tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}], }) assert_that(GameState.current_zone_id).is_equal("") GameState.apply_snapshot({"tick": 2}) 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() func test_d073_rapid_zone_crossing_interruptible() -> void: ## D-073: Rapid back-and-forth zone crossing (interruptible crossfade). ## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate ## volume — new tween starts from current position. No stacked tweens, no crash. if not "ZONE_ASSETS" in AudioManager: push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test") return # Cross hub → bar → hub rapidly (simulates player walking back and forth) AudioManager.set_zone("hub") AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween ## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in). ## No stacked tweens — _kill_zone_tweens clears previous tweens each time. assert_that(AudioManager._ambient_players.size() <= 2).is_true() ## Zone tweens array should only contain active tweens from the last set_zone call. assert_that(AudioManager._zone_tweens.size() <= 2).is_true() AudioManager.stop_all_loops() func test_d068_load_prefs_persistence_roundtrip() -> void: ## #528: Volume persistence — save/load roundtrip via ConfigFile. ## Sets non-default volumes, saves, reloads, and verifies values match. AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5) AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0) ## _save_prefs() fires inside set_volume() — prefs file is written. ## Reload prefs by calling _load_prefs() directly. AudioManager._load_prefs() assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 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_UI_SOUNDS)).is_equal_approx(-12.0, 0.01) ## Reset to defaults for subsequent tests. for bus in AudioManager.BUSES: AudioManager.set_volume(bus, 0.0) func test_d068_load_prefs_handles_missing_config_gracefully() -> void: ## #528: _load_prefs() with a missing/corrupted config file must not crash. ## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early. ## This test documents the graceful fallback behavior. AudioManager._load_prefs() ## No assertion — absence of crash is the test. # ============================================================================== # 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")