extends Node class_name MovementStateMachineTest ## Unit tests for MovementStateMachine + states. Runs headless via ## movement/tests/run_fsm_tests.gd. Each test gets a fresh machine with the ## real state scripts attached, added to the tree so _ready registers states. ## Tests exercise transitions and bookkeeping — not physics (no move_and_slide ## against a real world here; the spawn smoke test covers integration). var sm: MovementStateMachine var fake_player: CharacterBody3D var params: MovementParams var fail_log := [] var tests_passed: int = 0 var tests_failed: int = 0 const STATE_SCRIPTS := { "state_ground": "res://movement/states/state_ground.gd", "state_air": "res://movement/states/state_air.gd", "state_wall_run": "res://movement/states/state_wall_run.gd", "state_wall_cling": "res://movement/states/state_wall_cling.gd", "state_slide": "res://movement/states/state_slide.gd", "state_dash": "res://movement/states/state_dash.gd", } func run_all() -> void: print("=== MovementStateMachine tests ===") tests_passed = 0 tests_failed = 0 var tests := [ "test_states_register_and_initialize", "test_do_jump_sets_velocity_and_counters", "test_held_jump_bunny_hops_emit_each_takeoff", "test_double_jump_emits_jet_event", "test_wall_run_preserves_fast_entry_speed", "test_wall_run_entry_keeps_some_upward_momentum", "test_dash_cooldown_is_per_machine", "test_dash_has_two_charges_then_recharges", "test_dash_adds_speed_to_forward_momentum", "test_chain_bonus_soft_caps", "test_notify_landed_emits_scaled_event", "test_ground_release_has_short_stopping_glide", "test_slide_entry_boosts_and_keeps_direction", "test_grapple_state_registered_by_machine", ] for t in tests: _fresh() call(t) var ok: bool = fail_log.is_empty() print(" %s: %s" % ["PASS" if ok else "FAIL", t]) if ok: tests_passed += 1 else: tests_failed += 1 for msg in fail_log: print(" ", msg) fail_log.clear() _teardown() print("=== Results: %d passed, %d failed ===" % [tests_passed, tests_failed]) if tests_failed > 0: print("SOME TESTS FAILED") get_tree().quit(1) else: print("ALL TESTS PASSED") get_tree().quit(0) # ── helpers ───────────────────────────────────────────────────────────────── func _fresh() -> void: params = MovementParams.new() fake_player = load("res://movement/tests/fake_player.gd").new() add_child(fake_player) sm = MovementStateMachine.new() sm.player = fake_player sm.params = params for state_name in STATE_SCRIPTS: var st := Node.new() st.name = state_name st.set_script(load(STATE_SCRIPTS[state_name])) sm.add_child(st) fake_player.add_child(sm) # triggers _ready: registers states + injected ones sm.set_physics_process(false) # tests drive the machine manually func _teardown() -> void: if is_instance_valid(fake_player): remove_child(fake_player) fake_player.free() sm = null fake_player = null func _expect(cond: bool, msg: String) -> void: if not cond: fail_log.append(msg) func _eq(a: Variant, b: Variant, msg: String = "") -> void: if a != b: fail_log.append(msg if not msg.is_empty() else ("expected %s == %s" % [a, b])) # ── tests ─────────────────────────────────────────────────────────────────── func test_states_register_and_initialize() -> void: # All six explicit states plus the two the machine injects itself. for expected in ["ground", "air", "wall_run", "wall_cling", "slide", "dash", "grapple", "wall_climb"]: _expect(sm.states.has(expected), "state '%s' should be registered" % expected) _expect(not sm.current_state.is_empty(), "machine should start in a state") sm.switch_to("ground") _eq(sm.current_state, "ground") sm.switch_to("air") _eq(sm.current_state, "air") func test_do_jump_sets_velocity_and_counters() -> void: sm.switch_to("ground") sm.on_ground = true fake_player.velocity = Vector3(5, 0, 0) sm.do_jump() _expect(fake_player.velocity.y == params.jump_velocity, "jump sets vertical velocity") _expect(fake_player.velocity.x == 5.0, "jump keeps horizontal momentum") _eq(sm.current_jump_count, 1, "jump count set") _expect(sm.jump_cooldown_timer > 0.0, "jump cooldown armed") _expect(not sm.on_ground, "no longer grounded") func test_held_jump_bunny_hops_emit_each_takeoff() -> void: var jump_events: Array[String] = [] var controller := PlayerMovementController.new() controller._machine = sm sm.movement_event.connect(func(event_name: String, data: Dictionary) -> void: if event_name == "jump": jump_events.append(String(data.get("kind", "jump"))) controller._on_movement_event(event_name, data) ) var air = sm.states["air"] sm.input_jump_pressed = true fake_player.velocity = Vector3(0.0, 0.0, -params.walk_speed) var variants: Array[String] = [] # Start with an ordinary ground jump, then keep Space held through three # direct Air -> Air bunny hops. No intervening motion state is involved. sm.do_jump() variants.append(controller._visual_animation_state("air", params.walk_speed)) for hop in 3: sm.jump_cooldown_timer = 0.0 sm.notify_landed(0.0) _expect(air._try_bunny_hop(), "held jump starts bunny hop %d" % (hop + 1)) variants.append(controller._visual_animation_state( "air", params.walk_speed)) _eq(jump_events, ["jump", "bunny_hop", "bunny_hop", "bunny_hop"], "every held-space bunny hop emits a jump takeoff") _eq(variants, ["air_alt", "air", "air_alt", "air"], "ground jump and consecutive bunny hops alternate leg variants") controller._machine = null controller.free() func test_double_jump_emits_jet_event() -> void: var events := [] sm.movement_event.connect(func(ev, data): events.append([ev, data])) sm.switch_to("air") sm.current_jump_count = 1 sm.jump_cooldown_timer = 0.0 sm.input_jump_just_pressed = true sm.states["air"].update(0.016) _expect(fake_player.velocity.y == params.double_jump_velocity, "double jump sets its own vertical impulse") _expect(events.any(func(item): return item[0] == "double_jump"), "double jump emits the replicated jet event") func test_wall_run_preserves_fast_entry_speed() -> void: # Enter a wall run at 20 m/s along the wall; speed must not drop to base. sm.wall_normal = Vector3.RIGHT sm.wall_side = -1.0 fake_player.velocity = Vector3(0, 0, -20) # along wall tangent sm.switch_to("wall_run") var hspeed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length() _expect(hspeed >= 19.9, "wall run must keep entry speed (got %.1f)" % hspeed) func test_wall_run_entry_keeps_some_upward_momentum() -> void: sm.wall_normal = Vector3.RIGHT fake_player.velocity = Vector3(0, 20.0, -10) sm.switch_to("wall_run") _expect(fake_player.velocity.y > 0.0, "some upward carry should remain") _expect(fake_player.velocity.y <= params.wall_run_entry_max_up + 0.001, "upward carry capped at wall_run_entry_max_up") func test_dash_cooldown_is_per_machine() -> void: fake_player.velocity = Vector3(10, 0, 0) sm.wish_dir_world = Vector3(1, 0, 0) sm.switch_to("dash") _expect(sm.get_dash_charges() == params.dash_charges - 1, "dash spends one charge") _expect(sm.dash_recharge_timer > 0.0, "dash arms this machine's recharge clock") # A second, independent machine must NOT share that state (the old # implementation used a static var shared across all players). var player2 = load("res://movement/tests/fake_player.gd").new() add_child(player2) var sm2 := MovementStateMachine.new() sm2.player = player2 sm2.params = MovementParams.new() player2.add_child(sm2) _expect(sm2.get_dash_charges() == sm2.params.dash_charges, "second player keeps full charges") remove_child(player2) player2.free() func test_dash_has_two_charges_then_recharges() -> void: params.dash_charges = 2 sm.wish_dir_world = Vector3(1, 0, 0) fake_player.velocity = Vector3(10, 0, 0) _expect(sm.can_dash(), "first dash available") sm.switch_to("dash") sm.switch_to("air") _expect(sm.can_dash(), "second charge still banked — no cooldown wait") _eq(sm.get_dash_cooldown_remaining(), 0.0, "cooldown reads 0 while a charge is banked") sm.switch_to("dash") sm.switch_to("air") _expect(not sm.can_dash(), "both charges spent → dash blocked") _expect(sm.get_dash_cooldown_remaining() > 0.0, "cooldown now counts to next charge") # Simulate the recharge tick the machine runs each physics frame. # Blank the state so only machine bookkeeping runs (no state update / # move_and_slide outside a real physics frame). sm.current_state = "" sm.dash_recharge_timer = 0.0001 sm._physics_process(0.016) _expect(sm.can_dash(), "one charge regained after recharge timer elapses") _expect(sm.dash_recharge_timer > 0.0, "recharge continues toward the second charge") func test_dash_adds_speed_to_forward_momentum() -> void: fake_player.velocity = Vector3(12, 0, 0) sm.wish_dir_world = Vector3(1, 0, 0) sm.switch_to("dash") var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length() _expect(speed >= 12.0 + params.dash_speed - 0.01, "forward dash should add dash_speed on top of momentum (got %.1f)" % speed) func test_chain_bonus_soft_caps() -> void: params.chain_bonus_per_success = 0.05 params.chain_bonus_cap = 0.25 for i in range(12): sm.register_chain_mechanic("jump") _eq(sm.chain_count, 12, "chain count accumulates") _expect(sm.current_chain_bonus <= params.chain_bonus_cap + 0.35, "bonus stays near cap (soft cap)") _expect(sm.current_chain_bonus >= params.chain_bonus_cap, "soft cap still rewards long chains slightly") func test_notify_landed_emits_scaled_event() -> void: var events := [] sm.movement_event.connect(func(ev, data): events.append([ev, data])) sm.notify_landed(2.0) # soft: below land_soft_speed → no event _expect(events.is_empty(), "gentle landings emit no event") sm.notify_landed(params.land_heavy_speed + 1.0) _expect(events.size() == 1 and events[0][0] == "land", "heavy landing emits land event") if events.size() == 1: _expect(events[0][1].get("heavy", false), "heavy flag set above land_heavy_speed") _expect(sm.current_jump_count == 0, "landing resets jump count") func test_ground_release_has_short_stopping_glide() -> void: var ground = sm.states["ground"] var velocity := Vector3(0.0, 0.0, -params.walk_speed) var distance := 0.0 var first_frame_speed := 0.0 for frame in 90: velocity = ground._next_horizontal_velocity( velocity, Vector3.ZERO, false, 1.0 / 60.0) if frame == 0: first_frame_speed = velocity.length() distance += velocity.length() / 60.0 if velocity.length() <= 0.6: break _expect(first_frame_speed > params.walk_speed * 0.80, "release keeps most momentum on its first frame") _expect(distance > 0.75 and distance < 1.5, "release produces a short stopping glide (%.2f m)" % distance) func test_slide_entry_boosts_and_keeps_direction() -> void: sm.switch_to("ground") fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed # Slide entry boost only applies when is_on_floor(); fake player isn't on a # real floor, so just verify direction is kept and speed never decreases. sm.switch_to("slide") _expect(fake_player.velocity.z < 0.0, "slide keeps travel direction") var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length() _expect(speed >= 12.0 - 0.01, "slide entry must not lose speed (got %.1f)" % speed) _expect(sm.states.has("slide"), "slide state present") func test_grapple_state_registered_by_machine() -> void: _expect(sm.states.has("grapple"), "machine injects grapple state") _expect(sm.states.has("wall_climb"), "machine injects wall_climb state") sm.grapple_length = 10.0 sm.switch_to("grapple") _eq(sm.current_state, "grapple")