test(client): sprint 15 validation tests — camera, UI framework, entities

Add test_smooth_camera_sprint15.gd (11 tests): lerp convergence,
teleport snap, D-015 fixed-north, constant range validation.
Add test_ui_framework_sprint15.gd (20 tests): D-049 z-layer hierarchy,
OQ-07 insert_active, #241 follow stub, Sprint 14 regression checks.
Update test_camera_anchor.gd and test_client_p3.gd for ENTITY_OFFSET_X/Y
split and manual lerp camera behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:10:06 +01:00
co-authored by Claude Opus 4.6
parent 176052dfd6
commit e616af6eca
6 changed files with 515 additions and 27 deletions
+22 -19
View File
@@ -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)
+8 -8
View File
@@ -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
+211
View File
@@ -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.020.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)
@@ -0,0 +1 @@
uid://s15smoothcam1
+272
View File
@@ -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()
@@ -0,0 +1 @@
uid://s15uiframe001