Merge remote-tracking branch 'origin/client'

This commit is contained in:
2026-02-19 15:56:57 +01:00
13 changed files with 1022 additions and 5 deletions
+293
View File
@@ -0,0 +1,293 @@
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
## Spec refs:
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
## D-069: Audio dip profiles — confrontation drops WorldSFX 4–6dB
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
##
## Test layers:
## 1. D-068 Bus architecture constants (no implementation required)
## 2. D-069 Dip profile spec values (no implementation required)
## 3. AudioManager API — dip state machine, signal emission
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
class_name TestAudioBusRouting
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
func after_test() -> void:
AudioManager.clear_dip()
# ==============================================================================
# Layer 1: D-068 Bus Architecture Constants
# ==============================================================================
func test_d068_bus_world_sfx_name() -> void:
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d068_bus_ambient_name() -> void:
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
func test_d068_bus_player_actions_name() -> void:
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
func test_d068_bus_ui_sounds_name() -> void:
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
func test_d068_bus_music_name() -> void:
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
func test_d068_five_buses_defined() -> void:
## D-068: Exactly 5 buses in the architecture.
assert_that(AudioManager.BUSES.size()).is_equal(5)
func test_d068_all_bus_names_present_in_buses_array() -> void:
## D-068: BUSES array must contain all 5 named buses.
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
func test_d068_bus_names_are_unique() -> void:
## D-068: No two buses share a name.
var seen: Dictionary = {}
for bus_name in AudioManager.BUSES:
assert_that(seen.has(bus_name)).is_false()
seen[bus_name] = true
# ==============================================================================
# Layer 2: D-069 Dip Profile Spec Values
# Close-range sound events go on WorldSFX; confrontation dips that bus 4–6 dB.
# ==============================================================================
func test_d069_three_dip_profiles_exist() -> void:
## D-069: Three profiles — dialogue, confrontation, listening_focus.
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
## D-069: Confrontation must drop WorldSFX by 4–6 dB. Sprint 12 uses -5.0 dB.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
var dip_db: float = buses["WorldSFX"]
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
func test_d069_confrontation_dips_ambient() -> void:
## D-069: Confrontation suppresses both Ambient and WorldSFX.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("Ambient")).is_true()
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
assert_that(spec.has("filter_hz")).is_true()
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
## Confrontation is the one that mutes world SFX.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
func test_d069_listening_focus_boosts_world_sfx() -> void:
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
assert_that(buses["WorldSFX"] > 0.0).is_true()
func test_d069_all_profiles_have_positive_ease_times() -> void:
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
for profile in AudioManager.DIP_SPECS.keys():
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
assert_that(spec.has("ease_in")).is_true()
assert_that(spec.has("ease_out")).is_true()
assert_that(spec["ease_in"] > 0.0).is_true()
assert_that(spec["ease_out"] > 0.0).is_true()
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(conf_out >= dial_out).is_true()
# ==============================================================================
# Layer 2b: D-067 Recognition Chime
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
# ==============================================================================
func test_d067_chime_recognition_constant_exists() -> void:
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
## "Neural lattice firing" feel — soft crystalline tone.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
## No further assertion needed — play() default bus IS UISounds by design.
func test_d067_chime_noop_when_asset_absent() -> void:
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
## play(CHIME_RECOGNITION) must be a no-op (no crash).
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 3: AudioManager API — Dip State Machine
# ==============================================================================
func test_audio_manager_initial_state_no_active_dip() -> void:
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_sets_active() -> void:
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_audio_manager_clear_dip_resets_active() -> void:
AudioManager.apply_dip("confrontation")
AudioManager.clear_dip()
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
## Unknown profile: push_warning + early return, active dip stays empty.
AudioManager.apply_dip("not_a_real_profile")
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
## apply_dip() must emit dip_changed(profile) synchronously.
var received_profile := ""
var conn := func(p: String) -> void: received_profile = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("dialogue")
AudioManager.dip_changed.disconnect(conn)
assert_that(received_profile).is_equal("dialogue")
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
## clear_dip() must emit dip_changed("") to signal audio restored.
AudioManager.apply_dip("dialogue")
var received_profile := "sentinel"
var conn := func(p: String) -> void: received_profile = p
AudioManager.dip_changed.connect(conn)
AudioManager.clear_dip()
AudioManager.dip_changed.disconnect(conn)
assert_that(received_profile).is_equal("")
func test_audio_manager_apply_dip_interrupts_previous() -> void:
## Switching profiles mid-dip: active profile must update to the new one.
AudioManager.apply_dip("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
## Switching from dialogue to confrontation emits dip_changed("confrontation").
AudioManager.apply_dip("dialogue")
var received_profile := ""
var conn := func(p: String) -> void: received_profile = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("confrontation")
AudioManager.dip_changed.disconnect(conn)
assert_that(received_profile).is_equal("confrontation")
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
## has_asset() must return false for a key that was never registered.
## Ensures no-op fallback path (D-038) is correctly guarded.
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
# ==============================================================================
# Layer 4: D-018 Snapshot Integration — #125 implemented
# GameState partitions sound_events by range_category into close_sound_events
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
# ==============================================================================
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
## #125: Close-range events must land in GameState.close_sound_events.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
## #126/#125: Medium-range events land in medium_sound_events, not close.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(0)
func test_snapshot_partitions_close_and_medium_events() -> void:
## D-018: Mixed batch — Close and Medium events partitioned correctly.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events.size()).is_equal(1)
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
## When snapshot has no sound_events, both close and medium arrays are cleared.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.close_sound_events.size()).is_equal(0)
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# ==============================================================================
# Layer 5: AudioManager.play_sound_event — asset registry and routing
# ==============================================================================
func test_audio_manager_sound_event_assets_registry_exists() -> void:
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
## #125 / D-038: Unknown event types must be silently skipped (no-op).
## play_sound_event should not crash and should not emit sound.
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
## #125: Empty event type string must be a no-op.
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
+199
View File
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
# Regression test for #345: protocol uses entity_id (not id).
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
func test_entity_renderer_rejects_old_id_field_format() -> void:
var renderer := _make_entity_renderer()
# Old (wrong) format: "id" instead of "entity_id"
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(0)
# Correct format: "entity_id" — should be accepted
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_entity_renderer_player_color_differs_from_npc() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities)
@@ -334,6 +348,191 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- EntityRenderer: regression #345 (entity_id field name bug) --
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
# entity_data.has("entity_id"), causing all entities to be silently dropped.
# Fix: use entity_id (Protocol v2 canonical field name).
func test_regression_345_entity_id_field_renders() -> void:
# Regression: entity with correct "entity_id" field MUST be rendered.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_regression_345_old_id_field_not_rendered() -> void:
# Negative regression: entity using OLD field name "id" (not "entity_id")
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
# Only the entity_id entity should appear — no cross-contamination.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(1)).is_true()
assert_that(renderer.entity_nodes.has(2)).is_false()
renderer.queue_free()
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
# not by a string "entity_id" or by the old "id" value.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.has(42)).is_true()
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
assert_that(renderer.entity_nodes.has(0)).is_false()
renderer.queue_free()
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
# Regression: entity rendered via entity_id must have correct pixel position.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
var node = renderer.entity_nodes[5]
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
renderer.queue_free()
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
func _make_sound_indicator_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(SoundIndicatorScript)
add_child(renderer)
return renderer
func test_sound_indicator_accepts_medium_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(renderer._indicators.size()).is_equal(2)
renderer.queue_free()
func test_sound_indicator_drops_events_without_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"event_type": "Footstep"}, # missing x, y
{"x": 5.0, "event_type": "Voice"}, # missing y
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_appends_new_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
])
assert_that(renderer._indicators.size()).is_equal(2)
# New update appends — existing indicators persist until they expire
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
assert_that(renderer._indicators.size()).is_equal(3)
renderer.queue_free()
func test_sound_indicator_empty_update_preserves_existing() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Empty update does not clear existing indicators — they expire via _process
renderer.update_sound_events([])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_color_voice() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
renderer.queue_free()
func test_sound_indicator_color_danger() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
renderer.queue_free()
func test_sound_indicator_color_neutral_for_unknown() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
renderer.queue_free()
func test_sound_indicator_deduplicates_same_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Same position again — should reset timer, not add a duplicate
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_elapsed_starts_at_zero() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
renderer.queue_free()
func test_sound_indicator_events_expire_after_lifetime() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
# Manually age the indicator past its lifetime
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
renderer._process(0.0) # zero delta so no additional aging
assert_that(renderer._indicators.size()).is_equal(0)
renderer.queue_free()
# -- GameState: medium_sound_events from snapshot --
func test_game_state_filters_medium_sound_events() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
GameState.apply_snapshot({"tick": 1})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
# Next tick without sound_events clears them
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+236
View File
@@ -0,0 +1,236 @@
## Test suite for #126: Medium-range visual indicators (D-018)
## Spec refs:
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
## D-049: Z-stack — indicators must render above fog layer (z:900)
## D-069: Color palette for indicators (neutral/voice/danger)
##
## Color spec (sprint brief + D-018/D-069):
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
##
## Test layers:
## 1. D-018 Indicator color spec — verifies Constants match the spec
## 2. D-018 Range routing — Medium vs Close events belong to different systems
## 3. Direction geometry — angle from player position to sound source
## 4. Snapshot integration stubs — graceful skip until #126 lands
## 5. Indicator script API — graceful skip until Stig creates the script
class_name TestSoundIndicators
extends GdUnitTestSuite
# ==============================================================================
# Layer 1: D-018/D-069 Indicator Color Spec
# Sound indicators reuse existing Constants to avoid palette drift.
# ==============================================================================
func test_d018_indicator_color_neutral_is_insert_color() -> void:
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
## Indicators use the insert visual language (diegetic, not world-layer UI).
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
func test_d018_indicator_color_voices_is_poi_amber() -> void:
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d018_indicator_color_danger_is_hostile_red() -> void:
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d018_three_indicator_colors_are_distinct() -> void:
## D-018: All three indicator states must be visually distinct.
var neutral: Color = Constants.INSERT_COLOR_TEXT
var voice: Color = Constants.ENTITY_COLOR_POI
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
assert_that(neutral != voice).is_true()
assert_that(neutral != danger).is_true()
assert_that(voice != danger).is_true()
func test_d049_fog_layer_z_index() -> void:
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
## This verifies the z constant — indicator placement depends on it.
assert_that(Constants.Z_FOG).is_equal(900)
# ==============================================================================
# Layer 2: D-018 Range Routing — Medium vs Close
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
# Medium-range events → fog-edge indicators (no direct audio)
# ==============================================================================
func test_d018_medium_range_events_stored_in_snapshot() -> void:
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
## D-018: Close events must NOT appear in medium_sound_events.
## They go to close_sound_events for 2D positional audio.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(1)
func test_d018_medium_range_event_has_position_fields() -> void:
## Medium-range events must include source position for direction calculation.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
var ev: Dictionary = GameState.medium_sound_events[0]
assert_that(ev.has("x")).is_true()
assert_that(ev.has("y")).is_true()
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
# ==============================================================================
# Layer 3: Direction Geometry
# The fog-edge indicator must point toward the sound source from the player.
# These tests validate the math used by the indicator rendering — if the
# indicator uses Vector2.angle() or atan2, these confirm expected output.
# ==============================================================================
## Compute directional angle (radians) from player tile pos to source tile pos.
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
func _direction_angle(player: Vector2, source: Vector2) -> float:
return player.direction_to(source).angle()
func test_indicator_direction_north() -> void:
## Source directly north of player (lower y in Godot) → angle -PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
func test_indicator_direction_east() -> void:
## Source directly east → angle 0.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
assert_that(angle).is_equal_approx(0.0, 0.01)
func test_indicator_direction_south() -> void:
## Source directly south (higher y) → angle PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
func test_indicator_direction_west() -> void:
## Source directly west → angle ≈ ±PI.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
func test_indicator_direction_northeast() -> void:
## Source equal offset northeast → angle -PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
func test_indicator_direction_southeast() -> void:
## Source equal offset southeast → angle PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
func test_indicator_direction_southwest() -> void:
## Source equal offset southwest → angle 3PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
func test_indicator_direction_changes_with_source_position() -> void:
## Sanity: direction is not degenerate — different source positions give different angles.
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(north != east).is_true()
assert_that(east != south).is_true()
assert_that(south != north).is_true()
func test_indicator_direction_magnitude_independent_of_distance() -> void:
## Direction angle must not vary with distance — only with angle from player.
## Source 5 tiles north vs 20 tiles north: same angle.
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
assert_that(near).is_equal_approx(far, 0.01)
# ==============================================================================
# Layer 4: Indicator Script API (graceful stub)
# Tests activate when Stig creates the indicator rendering script.
# Expected paths checked in order — first match wins.
# ==============================================================================
func _load_indicator_script() -> GDScript:
## Load the sound indicator renderer script (#126 implemented).
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
if ResourceLoader.exists(path):
return load(path) as GDScript
return null
func test_indicator_script_exists() -> void:
## #126: sound_indicator_renderer.gd must exist at the canonical path.
var script: GDScript = _load_indicator_script()
assert_that(script != null).is_true()
func test_indicator_has_update_sound_events_method() -> void:
## #126: Indicator must expose update_sound_events(events: Array).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var method_names: Array = script.get_script_method_list().map(
func(m: Dictionary) -> String: return m.name
)
assert_that(method_names.has("update_sound_events")).is_true()
func test_indicator_does_not_crash_for_empty_events() -> void:
## Edge case: empty sound_events list must not crash update_sound_events.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([])
# Empty update on fresh renderer — no indicators exist, no crash
assert_that(indicator._indicators.size()).is_equal(0)
indicator.queue_free()
func test_indicator_existing_survive_empty_update() -> void:
## Existing indicators persist through an empty update (expire via _process only).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.update_sound_events([])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.queue_free()
func test_indicator_stores_valid_medium_events() -> void:
## #126: update_sound_events must store events with x and y fields.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(indicator._indicators.size()).is_equal(2)
indicator.queue_free()