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>
360 lines
12 KiB
GDScript
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()
|