From 582060959478196e19bbe7f9b95dcc829507e7ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:14:37 +0100 Subject: [PATCH] =?UTF-8?q?feat(client):=20add=2016=20P2=20tests=20?= =?UTF-8?q?=E2=80=94=20camera,=20entity=20alpha/color,=20UI=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gdUnit4 tests covering: - Camera (5): zoom default, smoothing convergence, viewport tracking, follow movement, no-panning lock per D-014 - Entity alpha/color (4): null visibility default, peripheral/forward transition, terrain object color, player color constant - UI (7): monologue visible/hidden, interaction verbs, inventory grid, dialogue overlay, pause indicator, fog blob count, fog z_index Co-Authored-By: Claude Opus 4.6 --- client/tests/test_client_p2.gd | 313 +++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 client/tests/test_client_p2.gd diff --git a/client/tests/test_client_p2.gd b/client/tests/test_client_p2.gd new file mode 100644 index 000000000..b64e8d81e --- /dev/null +++ b/client/tests/test_client_p2.gd @@ -0,0 +1,313 @@ +## P2 client tests: camera (5), entity alpha/color (4), UI (7). +## Validates camera tracking, entity visual state, and UI component lifecycle. +## Spec ref: sprint-9/client.md #492. +class_name TestClientP2 +extends GdUnitTestSuite + +var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") +var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd") + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.game_time = {} + GameState.pending_recognitions = [] + GameState.nearby_interactions = [] + GameState.player_inventory = [] + GameState.player_stance = "Walk" + GameState.dialogue_active = false + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# -- Helpers ------------------------------------------------------------------- + +func _make_scene() -> Node: + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + return _instance + + +func _make_entity_renderer() -> Node2D: + var renderer = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + + +func _make_fog_entities() -> Node2D: + var node = Node2D.new() + node.set_script(FogEntitiesScript) + add_child(node) + return node + + +# -- Camera (5) ---------------------------------------------------------------- + +func test_camera_zoom_default_2x() -> void: + # P2-C01: Camera zoom should be 2x as set in main.tscn. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + assert_that(camera.zoom).is_equal(Vector2(2, 2)) + + +func test_camera_smoothing_convergence() -> void: + # P2-C02: After first _process, smoothing re-enables for gameplay feel. + # After several frames, camera position should still match player position + # (smoothing converges because target == position when stationary). + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + # First frame re-enables smoothing + inst._process(0.016) + assert_that(camera.position_smoothing_enabled).is_true() + # Several more frames — stationary player, camera converges + for i in 5: + inst._process(0.016) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).is_equal(expected) + + +func test_camera_viewport_tracks_player_position() -> void: + # P2-C03: Camera position always equals player_position * TILE_SIZE. + # No offset, no viewport clamping beyond player tracking. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).override_failure_message( + "Camera must track player position exactly" + ).is_equal(expected) + + +func test_camera_follows_player_after_movement() -> void: + # P2-C04: After player moves, camera position updates to new player position. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + var initial_pos := camera.global_position + # Move player south via SimBridge test mode + SimBridge._test_input_queue.append("MoveSouth") + inst._process(0.016) + # Camera should have moved with player + assert_that(camera.global_position.y > initial_pos.y).override_failure_message( + "Camera Y should increase after moving south" + ).is_true() + assert_that(camera.global_position).is_equal( + GameState.player_position * Constants.TILE_SIZE) + + +func test_camera_no_panning_locked_to_player() -> void: + # P2-C05 (D-014): Camera is locked to player — no panning. + # Camera position equals player_position * TILE_SIZE every frame. + var inst := _make_scene() + var camera: Camera2D = inst.get_node("Camera2D") + # Process multiple frames + for i in 10: + inst._process(0.016) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).override_failure_message( + "Frame %d: camera must be locked to player" % i + ).is_equal(expected) + + +# -- Entity alpha/color (4) --------------------------------------------------- + +func test_entity_null_visibility_defaults_full_alpha() -> void: + # P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[50] + assert_that(node.modulate.a).override_failure_message( + "Null visibility should default to full alpha" + ).is_equal_approx(1.0, 0.01) + renderer.queue_free() + + +func test_entity_peripheral_to_forward_alpha_transition() -> void: + # P2-E02: Updating entity from Peripheral to Forward changes alpha. + var renderer := _make_entity_renderer() + # First: Peripheral + var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}] + renderer.update_entities(entity_p) + var node = renderer.entity_nodes[60] + assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01) + # Update to Forward + var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}] + renderer.update_entities(entity_f) + assert_that(node.modulate.a).override_failure_message( + "Forward visibility should set alpha to 1.0" + ).is_equal_approx(1.0, 0.01) + renderer.queue_free() + + +func test_entity_terrain_uses_object_color() -> void: + # P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0, + "kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[70] as ColorRect + assert_that(node.color).override_failure_message( + "Terrain kind should use ENTITY_COLOR_OBJECT" + ).is_equal(Constants.ENTITY_COLOR_OBJECT) + renderer.queue_free() + + +func test_entity_player_color_regardless_of_sector() -> void: + # P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector. + GameState.player_entity_id = 80 + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}] + renderer.update_entities(entity) + var node = renderer.entity_nodes[80] as ColorRect + assert_that(node.color).override_failure_message( + "Player color must be constant regardless of visibility sector" + ).is_equal(Constants.ENTITY_COLOR_PLAYER) + # Alpha should still be dimmed for Peripheral + assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01) + renderer.queue_free() + + +# -- UI (7) -------------------------------------------------------------------- + +func test_monologue_display_visible_hidden() -> void: + # P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue. + var inst := _make_scene() + var mono = inst.get_node("UILayer/MonologueDisplay") + assert_that(mono.is_visible).override_failure_message( + "Monologue should start hidden" + ).is_false() + mono.show_monologue("Test thought.", 3.0) + assert_that(mono.is_visible).override_failure_message( + "Monologue should be visible after show_monologue" + ).is_true() + assert_that(mono.text_label.text).is_equal("Test thought.") + + +func test_interaction_list_shows_nearest_verb() -> void: + # P2-U02: When nearby_interactions are present, interaction list shows verbs. + var inst := _make_scene() + var ilist = inst.get_node("InsertOverlay/InteractionList") + GameState.nearby_interactions = [{ + "entity_id": 2, + "entity_type": "Npc", + "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "Observe", "label": "Observe", "priority": 2, "available": true}, + ], + }] + ilist.update_from_state() + assert_that(ilist.get_visible_verb_count()).override_failure_message( + "Interaction list should show 2 verbs" + ).is_equal(2) + assert_that(ilist.is_showing()).is_true() + # Cleanup + GameState.nearby_interactions = [] + + +func test_inventory_grid_shows_carried_items() -> void: + # P2-U03: Inventory grid becomes visible when player has items. + var inst := _make_scene() + var inv = inst.get_node("UILayer/InventoryGrid") + # Initially empty — hidden + GameState.player_inventory = [] + inv.update_from_state() + assert_that(inv.visible).override_failure_message( + "Inventory should be hidden when empty" + ).is_false() + # Add items + GameState.player_inventory = [ + {"item_id": 1, "name": "Keycard", "slot": 0}, + {"item_id": 2, "name": "Datapad", "slot": 1}, + ] + inv.update_from_state() + assert_that(inv.visible).override_failure_message( + "Inventory should be visible with items" + ).is_true() + assert_that(inv.get_slot_count()).is_equal(2) + # Cleanup + GameState.player_inventory = [] + + +func test_dialogue_overlay_active_on_show() -> void: + # P2-U04: Dialogue box reports active after show_dialogue. + var inst := _make_scene() + var dlg = inst.get_node("InsertOverlay/DialogueBox") + assert_that(dlg.is_dialogue_active()).override_failure_message( + "Dialogue should start inactive" + ).is_false() + dlg.show_dialogue("Kael", "How's it going?", [ + {"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false}, + ]) + assert_that(dlg.is_dialogue_active()).override_failure_message( + "Dialogue should be active after show_dialogue" + ).is_true() + + +func test_game_state_tick_rate_paused() -> void: + # P2-U05: TickRate::Paused is stored in game_time and queryable. + GameState.apply_snapshot({ + "tick": 1, + "game_time": { + "day": 0, + "time_of_day": 100, + "day_phase": "Morning", + "tick_rate": "Paused", + }, + }) + assert_that(GameState.game_time.tick_rate).override_failure_message( + "Paused tick rate must be stored in game_time" + ).is_equal("Paused") + + +func test_fog_entities_blob_count_matches_recognitions() -> void: + # P2-U06: FogEntities tracks one blob per pending recognition. + var fog_entities := _make_fog_entities() + var saved := GameState.pending_recognitions + GameState.pending_recognitions = [ + {"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6}, + {"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6}, + ] + fog_entities.update_from_state() + assert_that(fog_entities._entities.size()).override_failure_message( + "FogEntities should track 2 blob entities" + ).is_equal(2) + assert_that(fog_entities._entities.has(101)).is_true() + assert_that(fog_entities._entities.has(102)).is_true() + GameState.pending_recognitions = saved + fog_entities.queue_free() + + +func test_fog_overlay_z_index_covers_world() -> void: + # P2-U07: FogOverlay renders above world content (z:900), ensuring + # occluded tiles are visually covered. + var inst := _make_scene() + var fog_overlay = inst.get_node("World/FogOverlay") + assert_that(fog_overlay.z_index).override_failure_message( + "FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG + ).is_equal(Constants.Z_FOG) + # FogEntities at z:950 — above fog, below insert overlay + var fog_entities = inst.get_node("World/FogEntities") + assert_that(fog_entities.z_index).override_failure_message( + "FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES + ).is_equal(Constants.Z_FOG_ENTITIES)