## #501: Hub teleport client UX — QA test suite ## Spec refs: D-020 (protocol), D-030 (testability) ## Sprint Completion Proof (joint.md): ## - Home key sends TeleportToHub in Gauntlet mode ## - 0.3s fade-to-black-and-back plays on teleport ## - Dialogue/monologue/interaction buffer cleared on teleport ## - Non-Gauntlet: action rejected, client shows no effect class_name TestHubTeleport extends GdUnitTestSuite const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD # -- Fixtures ------------------------------------------------------------------ var _gauntlet_snapshot := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, "player_facing": "North", "player_stance": "Walk", "player_inventory": [], "entities": [ {"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}, ], "tiles": [], "visible_tiles": [], "visible_positions": [], "nearby_interactions": [], "current_monologue": null, "current_dialogue": null, "pending_recognitions": [], "gauntlet_mode": true, "room_id": "proof_room", } var _normal_snapshot := { "tick": 1, "version": Protocol.PROTOCOL_VERSION, "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, "player_facing": "North", "player_stance": "Walk", "player_inventory": [], "entities": [ {"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}, ], "tiles": [], "visible_tiles": [], "visible_positions": [], "nearby_interactions": [], "current_monologue": null, "current_dialogue": null, "pending_recognitions": [], } func before_test() -> void: GameState.gauntlet_mode = false GameState.current_monologue = null GameState.current_dialogue = null GameState.dialogue_active = false GameState.room_id = null InputMapper.input_queue.clear() SimBridge.reset_test_state() # -- InputMapper: TELEPORT_HUB action enum ------------------------------------ func test_teleport_hub_action_exists() -> void: # Verify the enum value exists and is distinct var action: int = InputMapper.Action.TELEPORT_HUB assert_that(action).is_not_equal(InputMapper.Action.INTERACT) assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH) # -- InputMapper: Gauntlet mode guard ----------------------------------------- func test_teleport_hub_blocked_outside_gauntlet() -> void: # Non-gauntlet: Home key input should NOT queue TELEPORT_HUB GameState.gauntlet_mode = false InputMapper.input_queue.clear() var event := InputEventKey.new() event.physical_keycode = KEY_HOME event.pressed = true InputMapper._unhandled_input(event) var has_teleport := false for entry in InputMapper.input_queue: if entry.action == InputMapper.Action.TELEPORT_HUB: has_teleport = true assert_that(has_teleport).is_false() func test_teleport_hub_allowed_in_gauntlet_mode() -> void: # Gauntlet mode: Home key SHOULD queue TELEPORT_HUB GameState.gauntlet_mode = true InputMapper.input_queue.clear() var event := InputEventKey.new() event.physical_keycode = KEY_HOME event.pressed = true InputMapper._unhandled_input(event) var has_teleport := false for entry in InputMapper.input_queue: if entry.action == InputMapper.Action.TELEPORT_HUB: has_teleport = true assert_that(has_teleport).is_true() # -- GameState: gauntlet_mode from snapshot ------------------------------------ func test_gauntlet_mode_set_from_snapshot() -> void: GameState.apply_snapshot(_gauntlet_snapshot) assert_that(GameState.gauntlet_mode).is_true() assert_that(GameState.room_id).is_equal("proof_room") func test_gauntlet_mode_false_when_absent() -> void: GameState.apply_snapshot(_normal_snapshot) assert_that(GameState.gauntlet_mode).is_false() assert_that(GameState.room_id).is_null() func test_gauntlet_mode_transitions_off() -> void: # Gauntlet on → off: mode should clear GameState.apply_snapshot(_gauntlet_snapshot) assert_that(GameState.gauntlet_mode).is_true() GameState.apply_snapshot(_normal_snapshot) assert_that(GameState.gauntlet_mode).is_false() # -- SimBridge: wire format encoding ------------------------------------------- func test_teleport_hub_wire_name() -> void: # TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction) var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB) assert_that(wire_name).is_equal("TeleportToHub") func test_teleport_hub_wire_not_empty() -> void: # Wire name must not be empty (empty = client-only, not sent to server) var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB) assert_that(wire_name.is_empty()).is_false() func test_teleport_hub_encode_roundtrip() -> void: # Verify MessagePack encode→decode roundtrip for TeleportToHub var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub") assert_that(encoded.size()).is_greater(0) var decoded: Variant = Protocol.decode_player_input(encoded) assert_that(decoded).is_not_null() assert_that(decoded.tick).is_equal(42) assert_that(decoded.action.variant).is_equal("TeleportToHub") assert_that(decoded.action.data).is_null() # -- SimBridge: test mode teleport behavior ------------------------------------ func test_test_mode_teleport_resets_position() -> void: # In test mode, TeleportToHub should reset player to hub spawn (10, 10) SimBridge.reset_test_state() # Move player away first SimBridge._test_player_pos = Vector2i(50, 50) SimBridge._test_input_queue.append("TeleportToHub") var snap: Dictionary = SimBridge._test_snapshot() # Player should be back at hub spawn var player_entity: Dictionary = snap.entities[0] assert_that(player_entity.x).is_equal(10.0) assert_that(player_entity.y).is_equal(10.0) func test_test_mode_teleport_clears_dialogue() -> void: # TeleportToHub in test mode should clear dialogue state SimBridge.reset_test_state() SimBridge._test_in_dialogue = true SimBridge._test_input_queue.append("TeleportToHub") SimBridge._test_snapshot() assert_that(SimBridge._test_in_dialogue).is_false() # -- Teleport detection ------------------------------------------------------- # Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0). # These tests verify the distance math against that threshold. func test_detect_teleport_large_jump() -> void: # Position jump > threshold should be detected as teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(50.0, 50.0) assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true() func test_detect_teleport_normal_movement() -> void: # Normal 1-tile movement should NOT be detected as teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(11.0, 10.0) assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() func test_detect_teleport_diagonal_movement() -> void: # Diagonal movement (1,1) — distance ~1.41, not a teleport var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(11.0, 11.0) assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() func test_detect_teleport_boundary_exactly_threshold() -> void: # Exactly threshold — should NOT trigger (> not >=) var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(15.0, 10.0) assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false() func test_detect_teleport_boundary_just_over() -> void: # Just over threshold — should trigger var old_pos := Vector2(10.0, 10.0) var new_pos := Vector2(15.1, 10.0) assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true() # -- Buffer clearing on teleport ----------------------------------------------- func test_teleport_clears_dialogue_in_test_mode() -> void: # TeleportToHub in test mode should clear dialogue state via the pipeline SimBridge.reset_test_state() SimBridge._test_in_dialogue = true SimBridge._test_input_queue.append("TeleportToHub") var snap: Dictionary = SimBridge._test_snapshot() # Dialogue should be cleared by teleport assert_that(SimBridge._test_in_dialogue).is_false() assert_that(snap.current_dialogue).is_null() func test_teleport_resets_position_in_test_mode() -> void: # TeleportToHub must reset to hub spawn and clear dialogue (integration) SimBridge.reset_test_state() SimBridge._test_player_pos = Vector2i(50, 50) SimBridge._test_in_dialogue = true SimBridge._test_input_queue.append("TeleportToHub") var snap: Dictionary = SimBridge._test_snapshot() var player: Dictionary = snap.entities[0] assert_that(player.x).is_equal(10.0) assert_that(player.y).is_equal(10.0) assert_that(SimBridge._test_in_dialogue).is_false() # -- Send input integration (test mode) ---------------------------------------- func test_send_teleport_hub_in_test_mode() -> void: # Verify send_input accepts TELEPORT_HUB in test mode SimBridge.reset_test_state() SimBridge.state = SimBridge.ConnectionState.CONNECTED var err := SimBridge.send_input({ "action": InputMapper.Action.TELEPORT_HUB, "timestamp_msec": 12345, }) assert_that(err).is_equal(OK) func test_send_teleport_hub_queues_wire_action() -> void: # Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode SimBridge.reset_test_state() SimBridge.state = SimBridge.ConnectionState.CONNECTED SimBridge.send_input({ "action": InputMapper.Action.TELEPORT_HUB, "timestamp_msec": 12345, }) assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true() func test_gauntlet_mode_from_test_snapshot() -> void: # Verify test snapshot includes gauntlet_mode field SimBridge.reset_test_state() SimBridge._test_gauntlet_mode = true var snap: Dictionary = SimBridge._test_snapshot() assert_that(snap.gauntlet_mode).is_true() SimBridge._test_gauntlet_mode = false snap = SimBridge._test_snapshot() assert_that(snap.gauntlet_mode).is_false() # -- Live mode outbound encoding ----------------------------------------------- func test_teleport_hub_outbound_entry() -> void: # In live mode, TELEPORT_HUB should produce a valid outbound buffer entry # (We can't test full live mode in unit tests, but we test the encode path) var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub") assert_that(encoded.size()).is_greater(0) # Decode and verify var decoded: Variant = Protocol.decode_player_input(encoded) assert_that(decoded.action.variant).is_equal("TeleportToHub")