diff --git a/client/tests/test_camera_anchor.gd b/client/tests/test_camera_anchor.gd index 02e9196de..11a45cc05 100644 --- a/client/tests/test_camera_anchor.gd +++ b/client/tests/test_camera_anchor.gd @@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void: assert_that(camera.position_smoothing_enabled).is_false() +func test_camera_smoothing_stays_off_with_manual_lerp() -> void: + # #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always. + # CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + # --- Camera behavior across frames --- func test_camera_tracks_player_after_process() -> void: @@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void: assert_that(camera.global_position).is_equal(expected) -func test_camera_smoothing_reenabled_after_process() -> void: - # After the first anchored frame, smoothing should be back on for gameplay. - var scene := load("res://scenes/main.tscn") - _instance = scene.instantiate() - auto_free(_instance) - add_child(_instance) - - _instance._process(0.016) - - var camera: Camera2D = _instance.get_node("Camera2D") - assert_that(camera.position_smoothing_enabled).is_true() - - -func test_camera_follows_player_movement() -> void: +func test_camera_lerps_toward_player_movement() -> void: + # #117: With manual lerp, camera moves TOWARD player position (not snapping). + # After one 16ms frame the camera should be partway between old and new position. var scene := load("res://scenes/main.tscn") _instance = scene.instantiate() auto_free(_instance) add_child(_instance) var camera: Camera2D = _instance.get_node("Camera2D") - var initial_pos := camera.global_position + var initial_pos := camera.global_position # anchored at (320, 320) # Move player north via SimBridge test mode SimBridge._test_input_queue.append("MoveNorth") _instance._process(0.016) - # Camera should have moved with the player - assert_that(camera.global_position.y < initial_pos.y).is_true() - assert_that(camera.global_position).is_equal( - GameState.player_position * Constants.TILE_SIZE) + var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288) + # Camera should have moved north (lower y) but NOT reached the target yet + assert_that(camera.global_position.y).is_less(initial_pos.y) + assert_that(camera.global_position.y).is_greater(new_target.y) diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index faffb3dd8..f8f14d1cb 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void: renderer.update_entities(entity) var node = renderer.entity_nodes[10] var expected := Vector2( - floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) assert_that(node.position).override_failure_message( "Entity should snap to position on first appear (no lerp)" @@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void: renderer._process(0.016) var after_pos: Vector2 = node.position var target := Vector2( - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Position should have moved toward target (x increased) assert_that(after_pos.x > start_pos.x).override_failure_message( @@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void: "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity_moved) var target := Vector2( - floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s) for i in 20: @@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void: var small_progress: float = small_node.position.x - small_start # Reset position for large delta test small_node.position = Vector2( - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Large delta step var large_start: float = small_node.position.x diff --git a/client/tests/test_smooth_camera_sprint15.gd b/client/tests/test_smooth_camera_sprint15.gd new file mode 100644 index 000000000..864ec02bc --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd @@ -0,0 +1,211 @@ +## Sprint 15 — Smooth camera movement tests (#117) +## Validates exponential lerp, teleport snap, and configurable smoothing. +## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking). +class_name TestSmoothCameraSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + 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 + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# --- Configurable smoothing constant --- + +func test_camera_smoothing_speed_constant_defined() -> void: + # #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable). + assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true() + + +func test_camera_smoothing_speed_constant_reasonable() -> void: + # #117: Speed should produce smooth-but-responsive feel (2.0–20.0 range). + assert_that( + Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0 + ).is_true() + + +# --- Manual lerp, no Godot built-in smoothing --- + +func test_godot_smoothing_disabled_at_ready() -> void: + # #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +func test_godot_smoothing_stays_off_after_frames() -> void: + # #117: Smoothing must NOT be re-enabled at any point — manual lerp only. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + for i in range(5): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +# --- Interpolated tracking (no snap) --- + +func test_camera_lerps_not_snaps_on_player_move() -> void: + # #117: When player moves, camera should lerp (not snap) to new position. + # After 1 frame at ~60fps, camera should be partway there — not at target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var start_y := camera.global_position.y # anchored at player (10,10) → 320px + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + # Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320. + var target_y: float = GameState.player_position.y * Constants.TILE_SIZE + assert_that(camera.global_position.y < start_y).is_true() + assert_that(camera.global_position.y > target_y).is_true() + + +func test_camera_converges_to_player_over_multiple_frames() -> void: + # #117: After enough frames the camera should be within 1px of target. + # At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) # trigger the move, get new player position + + var target := GameState.player_position * Constants.TILE_SIZE + + # Run 40 frames (~0.67s at 60fps) — well past convergence for any speed ≥ 2.0 + for i in range(40): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + var dist := camera.global_position.distance_to(target) + assert_that(dist < 1.0).is_true() + + +func test_camera_stationary_player_no_drift() -> void: + # #117: When player is stationary, camera should not drift (lerp to same point). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var initial_pos := camera.global_position + + # Run several frames with no movement + for i in range(10): + _instance._process(0.016) + + # Camera should still be at anchored position (target = same point) + assert_that(camera.global_position).is_equal(initial_pos) + + +# --- Teleport snap --- + +func test_teleport_snaps_camera_immediately() -> void: + # #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame. + # Manually displace camera, set the flag, call _process — camera should snap to target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + + # Displace camera from its anchored position + camera.global_position = Vector2(0, 0) + # Set teleport flag — next _process() should snap to player target + _instance._camera_anchored = true + _instance._teleport_in_progress = true + + _instance._process(0.016) + + # Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).is_equal(expected) + + +func test_teleport_flag_cleared_after_snap() -> void: + # #117: _teleport_in_progress must be false after the snap frame. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + + assert_that(_instance._teleport_in_progress).is_false() + + +func test_camera_resumes_lerp_after_teleport() -> void: + # #117: Frame after teleport snap must resume lerp (not continue snapping). + # After teleport flag clears, any position delta produces lerp movement. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Frame 1: teleport snap — camera displaced, flag set, expect snap + var camera: Camera2D = _instance.get_node("Camera2D") + camera.global_position = Vector2(0, 0) + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + # After snap: camera at player position (10,10) = (320, 320) + var post_snap_y := camera.global_position.y + + # Frame 2: player moves north — camera should lerp, not snap + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE + # Camera must be between snap position and new target (lerping, not snapping) + assert_that(camera.global_position.y < post_snap_y).is_true() + assert_that(camera.global_position.y > new_target_y).is_true() + # Teleport flag must not be re-set by normal movement + assert_that(_instance._teleport_in_progress).is_false() + + +# --- D-015: Fixed-north camera --- + +func test_camera_no_rotation() -> void: + # D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.rotation).is_equal(0.0) + + _instance._process(0.016) + assert_that(camera.rotation).is_equal(0.0) diff --git a/client/tests/test_smooth_camera_sprint15.gd.uid b/client/tests/test_smooth_camera_sprint15.gd.uid new file mode 100644 index 000000000..e32aca28d --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15smoothcam1 diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd new file mode 100644 index 000000000..ac572294a --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd @@ -0,0 +1,272 @@ +## Sprint 15 — Basic UI framework validation tests (#74) +## Validates HUD structure, z-layer hierarchy, insert_active control, +## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07. +class_name TestUIFrameworkSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + 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.insert_active = true + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# ------------------------------------------------------------------------- +# D-049: Z-layer scene hierarchy +# ------------------------------------------------------------------------- + +func test_insert_overlay_is_canvas_layer_10() -> void: + # D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10. + # Constants.CANVAS_INSERT must match. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay") + assert_that(insert_overlay).is_not_null() + assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT) + + +func test_ui_layer_is_canvas_layer_20() -> void: + # D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var ui_layer: CanvasLayer = _instance.get_node("UILayer") + assert_that(ui_layer).is_not_null() + assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI) + + +func test_modal_layer_is_canvas_layer_30() -> void: + # D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var modal_layer: CanvasLayer = _instance.get_node("ModalLayer") + assert_that(modal_layer).is_not_null() + assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL) + + +func test_ui_layer_above_insert_overlay() -> void: + # D-049: UILayer (20) must render above InsertOverlay (10). + assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT) + + +func test_modal_layer_above_ui_layer() -> void: + # D-049: ModalLayer (30) must render above UILayer (20). + assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI) + + +# ------------------------------------------------------------------------- +# D-049: Required nodes exist in correct layers +# ------------------------------------------------------------------------- + +func test_monologue_display_exists_in_ui_layer() -> void: + # D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null() + + +func test_stance_indicator_exists_in_ui_layer() -> void: + # D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null() + + +func test_minimap_placeholder_exists_in_ui_layer() -> void: + # D-013: Minimap/insert placeholder must be in UILayer (not implemented yet). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null() + + +func test_hud_exists_in_ui_layer() -> void: + # D-049: HUD must be in UILayer. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null() + + +func test_interaction_list_exists_in_insert_overlay() -> void: + # D-057: InteractionList must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null() + + +func test_dialogue_box_exists_in_insert_overlay() -> void: + # D-061: DialogueBox must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null() + + +func test_world_radial_exists_in_insert_overlay() -> void: + # D-058: WorldRadial must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null() + + +func test_cursor_renderer_exists_in_ui_layer() -> void: + # D-056: CursorRenderer must be in UILayer (topmost, z-layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null() + + +# ------------------------------------------------------------------------- +# OQ-07 / D-056: insert_active controls z-layer 6 visibility +# ------------------------------------------------------------------------- + +func test_gamestate_insert_active_defaults_true() -> void: + # OQ-07: v0.1 characters all have inserts — default true. + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_propagates_on_process() -> void: + # OQ-07 (#522): After apply_snapshot with insert_active=false, + # the next _process() call must propagate the state to z-layer-6 nodes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Inject a snapshot with insert_active = false + var snap := SimBridge._test_snapshot() + snap["insert_active"] = false + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_false() + + +func test_insert_active_true_from_snapshot() -> void: + # OQ-07: Snapshot with insert_active=true keeps GameState in default-on state. + var snap := SimBridge._test_snapshot() + snap["insert_active"] = true + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_missing_field_defaults_true() -> void: + # OQ-07: Old servers without insert_active field must not disable the insert. + var snap := SimBridge._test_snapshot() + snap.erase("insert_active") + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +# ------------------------------------------------------------------------- +# #241 stub: follow_target_id for entity sprite system +# ------------------------------------------------------------------------- + +func test_follow_target_id_stub_exists() -> void: + # #72 / #241: follow_target_id stub must exist on GameState with default -1. + # Populated by server ticket #241 (Follow verb) when it lands. + assert_that(GameState.follow_target_id).is_equal(-1) + + +func test_follow_target_id_is_negative_one_by_default() -> void: + # #72: -1 means "not following" — client #72 checks this for entity highlight. + SimBridge.reset_test_state() + GameState.apply_snapshot(SimBridge._test_snapshot()) + # Server doesn't send follow_target_id yet — must stay -1 after snapshot + assert_that(GameState.follow_target_id).is_equal(-1) + + +# ------------------------------------------------------------------------- +# Monologue display wiring (#414) +# ------------------------------------------------------------------------- + +func test_monologue_display_receives_first_tick_monologue() -> void: + # #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot. + # Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Tick 1 snapshot has a monologue (SimBridge test mode) + # _ready() consumes tick 0 (no monologue). _process() here gets tick 1. + _instance._process(0.016) + + # If monologue_display received it, current_monologue is cleared (consume-once) + assert_that(GameState.current_monologue).is_null() + + +# ------------------------------------------------------------------------- +# Regression: Sprint 14 integration proofs (D-030 regression markers) +# ------------------------------------------------------------------------- + +func test_fog_group_exists_in_world() -> void: + # Sprint 14 regression: fog rendering must still be present after sprint 15 changes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null() + + +func test_floor_tiles_in_fog_group() -> void: + # Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null() + + +func test_entities_in_ysort_group() -> void: + # Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null() diff --git a/client/tests/test_ui_framework_sprint15.gd.uid b/client/tests/test_ui_framework_sprint15.gd.uid new file mode 100644 index 000000000..f76595a75 --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15uiframe001