Files
settled-reach/client/tests/test_audio_sprint13.gd
T
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
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>
2026-04-05 09:59:56 +02:00

635 lines
29 KiB
GDScript

## 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")