## Sprint 24 — Signal acceptance tests (#588, #590, #592) ## ## Client-side acceptance criteria: ## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence ## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id ## - #592: news_ticker decode + update_from_state hide/show behavior ## ## Spec: D-032 (monologue pools per character), D-016 (client displays server data only), ## D-042 (UI strings in yaml), D-067 (chime on recognition onset) class_name TestSignalSprint24 extends GdUnitTestSuite const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn") # -- #588: Character archetype field ------------------------------------------ func test_game_state_has_character_archetype_field() -> void: assert_bool("character_archetype" in GameState).override_failure_message( "GameState must have a character_archetype field (#588)" ).is_true() func test_game_state_character_archetype_default_is_detective() -> void: # Fresh GameState defaults to "detective" (safest fallback for legacy saves). var archetype = GameState.get("character_archetype") assert_str(archetype).override_failure_message( "GameState.character_archetype default must be 'detective'" ).is_equal("detective") func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void: # Unknown archetype strings must not silently pass garbage to the server. # The match guard falls back to "Detective" and calls push_error. var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker") var decoded = Messagepack.decode(bytes) assert_that(decoded.status).is_null() assert_str(decoded.value["character_archetype"]).override_failure_message( "Unknown archetype must fall back to 'Detective'" ).is_equal("Detective") func test_protocol_startup_message_includes_character_archetype() -> void: # StartupMessage wire payload must carry "character_archetype" key (#588). var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective") assert_bool(bytes.size() > 0).is_true() var decoded = Messagepack.decode(bytes) assert_that(decoded.status).is_null() var msg: Dictionary = decoded.value assert_bool(msg.has("character_archetype")).override_failure_message( "StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys()) ).is_true() func test_protocol_startup_message_detective_maps_to_pascal_case() -> void: # "detective" client string must map to "Detective" PascalCase server enum variant. var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective") var decoded = Messagepack.decode(bytes) assert_str(decoded.value["character_archetype"]).is_equal("Detective") func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void: # "smuggler" client string must map to "Smuggler" PascalCase server enum variant. var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler") var decoded = Messagepack.decode(bytes) assert_str(decoded.value["character_archetype"]).is_equal("Smuggler") func test_protocol_startup_message_preserves_world_seed() -> void: # Adding character_archetype must not break world_seed encoding. var seed: int = 0xDEADBEEF var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective") var decoded = Messagepack.decode(bytes) assert_int(decoded.value["world_seed"]).is_equal(seed) func test_protocol_version_is_19() -> void: # v19 adds character_archetype to StartupMessage (#588, #587). assert_that(Protocol.PROTOCOL_VERSION).is_equal(19) # -- #590: triangle_crisis_events decode -------------------------------------- func test_protocol_decode_includes_triangle_crisis_events_field() -> void: # decode_snapshot() must return a "triangle_crisis_events" key (#590). var raw := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], "triangle_crisis_events": [{"triangle_id": 42}], } var encoded = Messagepack.encode(raw) assert_that(encoded.status).is_null() var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_not_null() assert_bool(snapshot.has("triangle_crisis_events")).override_failure_message( "decode_snapshot must include triangle_crisis_events in returned dict" ).is_true() var events: Array = snapshot["triangle_crisis_events"] assert_bool(events.size() == 1).override_failure_message( "Expected 1 triangle_crisis_event, got: %d" % events.size() ).is_true() assert_int(events[0]["triangle_id"]).is_equal(42) func test_protocol_decode_triangle_crisis_events_empty_array() -> void: # When no events are present, field is present and empty. var raw := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], "triangle_crisis_events": [], } var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_not_null() var events: Array = snapshot.get("triangle_crisis_events", []) assert_int(events.size()).is_equal(0) func test_protocol_decode_triangle_crisis_events_absent_returns_empty() -> void: # When server doesn't send field (pre-#589), field defaults to empty array. var raw := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], } var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_not_null() var events: Array = snapshot.get("triangle_crisis_events", []) assert_int(events.size()).is_equal(0) func test_triangle_dedup_fires_chime_only_once_per_id() -> void: # _known_triangle_ids must prevent the same triangle_id from chiming twice. # We test the dedup dict directly — main.gd cannot be easily instantiated headless. # The dict is the single source of truth for dedup state. var seen: Dictionary = {} var chime_count: int = 0 # Simulate two ticks both containing triangle_id 42. for _tick in range(2): var tid: int = 42 if not seen.has(tid): seen[tid] = true chime_count += 1 assert_int(chime_count).override_failure_message( "Chime must fire exactly once per triangle_id across repeated ticks" ).is_equal(1) func test_triangle_dedup_fires_chime_for_each_unique_id() -> void: # Two distinct triangle_ids each chime once. var seen: Dictionary = {} var chime_count: int = 0 for tid in [42, 99]: if not seen.has(tid): seen[tid] = true chime_count += 1 assert_int(chime_count).override_failure_message( "Each unique triangle_id must chime independently" ).is_equal(2) # -- #592: current_ticker decode ---------------------------------------------- func test_protocol_decode_includes_current_ticker_field() -> void: # decode_snapshot() must return a "current_ticker" key (#592). var raw := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], "current_ticker": {"id": "ticker_001", "text": "Station systems nominal.", "category": "System"}, } var encoded = Messagepack.encode(raw) assert_that(encoded.status).is_null() var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_not_null() assert_bool(snapshot.has("current_ticker")).override_failure_message( "decode_snapshot must include current_ticker in returned dict" ).is_true() var ticker: Variant = snapshot["current_ticker"] assert_that(ticker).is_not_null() assert_str(ticker["text"]).is_equal("Station systems nominal.") func test_protocol_decode_current_ticker_null_when_absent() -> void: # When server doesn't send current_ticker (player outside bar zone), field is null. var raw := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], } var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_not_null() var ticker: Variant = snapshot.get("current_ticker") assert_that(ticker).is_null() # -- #592: NewsTicker show/hide behavior -------------------------------------- func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void: # update_from_state() must hide ticker when current_ticker is null. var ticker_scene := NEWS_TICKER_SCENE assert_that(ticker_scene).is_not_null() var ticker := ticker_scene.instantiate() auto_free(ticker) add_child(ticker) # Snapshot with no current_ticker (player outside bar zone). GameState.current_snapshot = { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], } ticker.update_from_state() assert_bool(ticker.visible).override_failure_message( "NewsTicker must be hidden when current_ticker is absent" ).is_false() func test_news_ticker_visible_when_snapshot_has_ticker() -> void: # update_from_state() must show ticker when current_ticker has text. var ticker_scene := NEWS_TICKER_SCENE assert_that(ticker_scene).is_not_null() var ticker := ticker_scene.instantiate() auto_free(ticker) add_child(ticker) GameState.current_snapshot = { "tick": 2, "version": Protocol.PROTOCOL_VERSION, "entities": [], "current_ticker": {"id": "t1", "text": "Station systems nominal.", "category": "System"}, } ticker.update_from_state() assert_bool(ticker.visible).override_failure_message( "NewsTicker must be visible when current_ticker has text" ).is_true() func test_news_ticker_hides_when_ticker_becomes_null() -> void: # Ticker shown then hidden: update_from_state() with null current_ticker hides it. var ticker_scene := NEWS_TICKER_SCENE assert_that(ticker_scene).is_not_null() var ticker := ticker_scene.instantiate() auto_free(ticker) add_child(ticker) # Show it first. GameState.current_snapshot = { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], "current_ticker": {"id": "t1", "text": "Breaking news.", "category": "System"}, } ticker.update_from_state() assert_bool(ticker.visible).is_true() # Null current_ticker — player left the bar zone. GameState.current_snapshot = { "tick": 2, "version": Protocol.PROTOCOL_VERSION, "entities": [], } ticker.update_from_state() assert_bool(ticker.visible).override_failure_message( "NewsTicker must hide when current_ticker returns to null" ).is_false()