Files
settled-reach/client/tests/test_cursor_states.gd
T
jpmschweitzerandClaude Opus 4.6 931399f248 feat(client): resolve OQ-07 — insert-off suppresses verb labels (#522)
Option (a): cursor shape still changes (body orients to targets), but
verb labels and interaction prompts are suppressed when insert_active
is false. Amends D-056 and D-057 with resolution note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:36 +01:00

360 lines
12 KiB
GDScript

## D-056: Cursor state machine tests (#429).
## Tests 4-state cursor transitions, timing, color accuracy, z-layer,
## and interaction suppression behavior per D-056 spec.
##
## These tests validate the cursor implementation once #429 lands.
## Some tests use mock data; others require the cursor scene/script.
##
## Spec refs: D-056, D-033, D-045, D-049
class_name TestCursorStates
extends GdUnitTestSuite
# -- Test helpers --------------------------------------------------------------
## Create cursor node from scene. Returns null if scene doesn't exist yet.
## Tests that call this will be skipped (not failed) if cursor isn't implemented.
func _make_cursor() -> Node:
var scene_path := "res://ui/cursor_state_machine.tscn"
if not ResourceLoader.exists(scene_path):
# Try alternative paths
for alt in ["res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(alt):
scene_path = alt
break
if not ResourceLoader.exists(scene_path):
return null
var scene = load(scene_path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
# Skip test: cursor not yet implemented
push_warning("TestCursorStates: cursor scene not found — test skipped (awaiting #429 implementation)")
return cursor
# -- State enumeration (D-056: exactly 4 states) -----------------------------
func test_cursor_has_four_states() -> void:
# Verify the cursor system defines exactly 4 states per D-056
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("get_state")).is_true()
# Default state should be the starting state
var initial_state = cursor.get_state()
assert_that(initial_state).is_not_null()
cursor.queue_free()
func test_cursor_initial_state_is_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default state — four thin inward-pointing ticks
var state = cursor.get_state()
assert_that(str(state)).is_equal("Default")
cursor.queue_free()
# -- State transitions (D-056: all transitions 150ms linear) ------------------
func test_transition_default_to_entity_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# Simulate hovering over an NPC entity
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_transition_default_to_object_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("ObjectHover")
cursor.queue_free()
func test_transition_default_to_weapon_aim() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
assert_that(str(cursor.get_state())).is_equal("WeaponAim")
cursor.queue_free()
func test_transition_entity_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
func test_transition_object_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- Timing (D-056: all transitions 150ms linear) ----------------------------
func test_transition_duration_constant() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "All transitions 150ms linear"
if cursor.has_method("get_transition_duration"):
var duration = cursor.get_transition_duration()
assert_float(duration).is_equal_approx(0.15, 0.001)
cursor.queue_free()
# -- Color accuracy (D-056 + D-033) -------------------------------------------
func test_default_cursor_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default = white-blue #c8d0e0
if cursor.has_method("get_cursor_color"):
var color = cursor.get_cursor_color()
var expected = Color("#c8d0e0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover = muted grey #8b8ba0
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": false})
var color = cursor.get_cursor_color()
var expected = Color("#8b8ba0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_flagged() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover (flagged) = amber #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": true})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_weapon_aim_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Weapon aim = warm white #f0e8d8, NO bloom
if cursor.has_method("set_weapon_mode") and cursor.has_method("get_cursor_color"):
cursor.set_weapon_mode(true)
var color = cursor.get_cursor_color()
var expected = Color("#f0e8d8")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Entity hover: D-033 relationship colors ----------------------------------
func test_entity_hover_unknown_uses_teal() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Unknown/Neutral = #4a9ebb
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
var color = cursor.get_cursor_color()
var expected = Color("#4a9ebb")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_friendly_uses_green() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Known/Friendly = #6bc9a6
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Friendly"})
var color = cursor.get_cursor_color()
var expected = Color("#6bc9a6")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_poi_uses_amber() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Person of Interest = #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "PersonOfInterest"})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_hostile_uses_red() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Hostile/Dangerous = #d45d5d
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Hostile"})
var color = cursor.get_cursor_color()
var expected = Color("#d45d5d")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Z-layer (D-049: cursor on layer 7) --------------------------------------
func test_cursor_z_layer() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: cursor on UI layer (CanvasLayer 20)
if cursor.has_method("get_z_layer"):
assert_that(cursor.get_z_layer()).is_equal(Constants.CANVAS_UI)
cursor.queue_free()
# -- Weapon mode suppression (D-056) -----------------------------------------
func test_weapon_mode_suppresses_interactions() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "Weapon-selected mode suppresses interaction prompts unless Shift held"
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_weapon_mode_shift_override() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Shift held in weapon mode restores interaction prompts
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Zone/narrative state invariance (D-045) ----------------------------------
func test_cursor_never_changes_by_zone() -> void:
# D-056: "Never changes by zone/narrative state (D-045)"
# This is a design constraint, not a unit test — but we verify the cursor
# doesn't accept zone/narrative state parameters.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("set_zone")).is_false()
assert_that(cursor.has_method("set_narrative_state")).is_false()
cursor.queue_free()
# -- LOS-gated cursor changes (D-056) ----------------------------------------
func test_cursor_changes_require_los() -> void:
# D-056: "Cursor changes on LOS, not just proximity"
# Entity hover should only activate if the entity is in LOS
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
# Entity NOT in LOS → cursor should stay Default
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": false})
# Should not transition to EntityHover if entity is not in LOS
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- OQ-07: Insert-off behavior (D-056 amendment, #522) ----------------------
# Option (a): cursor shape still changes, verb labels suppressed.
func test_insert_off_cursor_still_changes_shape() -> void:
# OQ-07 option (a): cursor state machine still fires when insert is off.
# The character's body orients toward targets even without insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("set_hover_target"):
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_insert_off_suppresses_interactions() -> void:
# OQ-07 option (a): with insert off, should_show_interactions() returns false.
# Verb labels (z-layer 6) are suppressed — no actionable insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_insert_on_restores_interaction_display() -> void:
# Re-enabling insert allows interactions to show again.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
# D-056: "Click interaction range: ~2 sim tiles"
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("get_interaction_range"):
var range_val = cursor.get_interaction_range()
# ~2 sim tiles = 1m per D-066
assert_that(range_val).is_equal(2)
cursor.queue_free()