extends "res://tests/test_case.gd" # Guards the input map and the InputSettings remap layer. # # The defect this file exists for: project.godot bound joypad events to only 5 # of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could # not translate at all. Nothing failed, because nothing asserted that a *pair* # of bindings exists — the actions were all present and the game booted fine. # test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion, # and it can tell "bound on both devices" from "bound on one", which is the # distinction that was actually missing. # # Several of these tests write to the global InputMap through InputSettings, so # each one must restore it before returning or it corrupts every later case in # the run (the runner shares one process). reset_all() is the restore. const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"] func _joypad_events(action: String) -> Array: var out := [] for event in InputMap.action_get_events(action): if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD: out.append(event) return out func _keyboard_events(action: String) -> Array: var out := [] for event in InputMap.action_get_events(action): if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD: out.append(event) return out func test_every_rebindable_action_exists() -> void: for entry in InputSettings.ACTIONS: assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"]) func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void: # The regression itself: a controller player must be able to reach every # action without touching the keyboard, and vice versa. for entry in InputSettings.ACTIONS: var action: String = entry["action"] assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action) assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action) func test_apply_is_lossless_against_the_project_defaults() -> void: # InputSettings stores one binding per device per action, so apply() # rewrites each action's event list to exactly [keyboard, joypad]. If # project.godot ever gains a second keyboard event for a rebindable action, # booting the game would silently drop it — the action would still work, on # fewer keys than the file says. Asserting apply() is a no-op over the # defaults is what distinguishes "bindings intact" from "bindings quietly # trimmed", which counting events cannot do. InputSettings.reset_all() var before := {} for entry in InputSettings.ACTIONS: before[entry["action"]] = InputMap.action_get_events(entry["action"]).size() InputSettings.apply() for entry in InputSettings.ACTIONS: var action: String = entry["action"] assert_eq( InputMap.action_get_events(action).size(), before[action], "%s keeps every event across apply()" % action ) assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action) func test_no_two_actions_share_a_joypad_binding() -> void: # Two flight actions sharing one input is silently unplayable rather than an # error, and it is easy to reintroduce: an early draft of this layout had A # as both turbo and thrust-up, and B as both thrust-down and ui_cancel. for entry in InputSettings.ACTIONS: var action: String = entry["action"] var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD) assert_true(bound != null, "%s resolves a joypad binding" % action) if bound == null: continue var conflicts := InputSettings.find_conflicts(bound, action) assert_true( conflicts.is_empty(), "%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)] ) func test_no_two_actions_share_a_keyboard_binding() -> void: for entry in InputSettings.ACTIONS: var action: String = entry["action"] var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD) assert_true(bound != null, "%s resolves a keyboard binding" % action) if bound == null: continue var conflicts := InputSettings.find_conflicts(bound, action) assert_true( conflicts.is_empty(), "%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)] ) func _joypad_buttons(action: String) -> Array: var out := [] for event in InputMap.action_get_events(action): if event is InputEventJoypadButton: out.append(event.button_index) return out func test_menus_are_usable_with_a_controller() -> void: # Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but # gives ui_accept and ui_cancel NO joypad binding at all (verified against a # pristine project). A controller could therefore move the highlight around # the main menu and never press anything — the menu looked responsive, which # is exactly why it went unnoticed. project.godot binds them explicitly. assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus") assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus") # Navigation is the engine default, but assert it so a future override of # these actions cannot silently strand a controller player again. for action in ["ui_up", "ui_down", "ui_left", "ui_right"]: var pad := 0 for event in InputMap.action_get_events(action): if event is InputEventJoypadButton or event is InputEventJoypadMotion: pad += 1 assert_true(pad > 0, "%s is reachable on a controller" % action) func test_leaving_gameplay_is_not_on_a_face_button() -> void: # game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on # ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far # too easy to hit by accident to also mean "abandon this match". The two # actions being distinct is the whole point, so assert they really differ. assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists") var buttons := _joypad_buttons("leave_gameplay") assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay") for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]: assert_true(face not in buttons, "leave_gameplay must not use a face button") func test_ball_cam_has_its_own_action_off_ui_accept() -> void: # Ball-cam used to ride ui_accept, which project.godot now binds to A for # menu confirmation. A dedicated action keeps A purely a menu button and # lets the camera toggle be rebound like anything else. assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists") var joypad := _joypad_events("toggle_ball_cam") assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding") if joypad.size() == 1: assert_true( joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A, "toggle_ball_cam is not on A (menu confirm)" ) func test_thrust_is_on_the_triggers() -> void: # The requested layout, asserted where it is load-bearing: triggers are the # only analog inputs on the thrust axis, so binding them to buttons instead # would silently cost proportional throttle without failing anything. var forward := _joypad_events("move_forward") var back := _joypad_events("move_back") assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis") assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis") if forward.size() == 1 and forward[0] is InputEventJoypadMotion: assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis") if back.size() == 1 and back[0] is InputEventJoypadMotion: assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis") func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void: var down := _joypad_events("pitch_down") var up := _joypad_events("pitch_up") assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis") assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis") if down.size() == 1 and down[0] is InputEventJoypadMotion: assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis") # Godot reports a stick pushed away from the player as negative Y. assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down") if up.size() == 1 and up[0] is InputEventJoypadMotion: assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis") assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up") func test_all_rotation_lives_on_the_left_stick() -> void: # Yaw and pitch belong on the same stick. Splitting them across two sticks # (yaw left, pitch right) is playable in the sense that every input works, # so nothing here failed when it was wrong — it just felt broken, because # each stick had a dead axis. Asserting both are on the right stick is what # pins the 6DOF convention down. for action in ["turn_left", "turn_right"]: var events := _joypad_events(action) assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action) if events.size() == 1 and events[0] is InputEventJoypadMotion: assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action) for action in ["pitch_up", "pitch_down"]: var events := _joypad_events(action) if events.size() == 1 and events[0] is InputEventJoypadMotion: assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action) func test_translation_is_analog_on_the_right_stick_and_triggers() -> void: # Six degrees of freedom onto the pad's six analog axes. Strafe and # vertical were digital buttons at first, which cost proportional control # without failing anything — a button binding here still "works", it just # gives full power or nothing, so only checking the event type catches it. var expected := { "move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X, "move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y, "move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT, } for action in expected: var events := _joypad_events(action) assert_true( events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is analog, not a button" % action ) if events.size() == 1 and events[0] is InputEventJoypadMotion: assert_eq(events[0].axis, expected[action], "%s axis" % action) func test_pushing_the_right_stick_up_thrusts_up() -> void: # Godot reports a stick pushed away from the player as negative Y, so the # intuitive direction needs the negative half — easy to get backwards, and # inverted vertical thrust is not something any other assertion notices. var up := _joypad_events("move_up") var down := _joypad_events("move_down") if up.size() == 1 and up[0] is InputEventJoypadMotion: assert_true(up[0].axis_value < 0.0, "stick up thrusts up") if down.size() == 1 and down[0] is InputEventJoypadMotion: assert_true(down[0].axis_value > 0.0, "stick down thrusts down") func test_roll_is_on_the_shoulder_buttons() -> void: var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER} for action in expected: var events := _joypad_events(action) assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action) if events.size() == 1 and events[0] is InputEventJoypadButton: assert_eq(events[0].button_index, expected[action], "%s button" % action) func test_the_face_buttons_are_free_for_menus() -> void: # A/B/X/Y carry no flight action, which is what lets ui_accept keep A and # keeps a stray face-button press from doing something during a match. for entry in InputSettings.ACTIONS: var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD) if bound is InputEventJoypadButton: assert_true( bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y], "%s must not use a face button" % entry["action"] ) func test_event_dict_round_trip_preserves_every_default() -> void: for entry in InputSettings.ACTIONS: for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]: var original := InputSettings.get_default_binding(entry["action"], device) assert_true(original != null, "%s/%s has a default" % [entry["action"], device]) if original == null: continue var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original)) assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device]) if restored != null: assert_true( InputSettings.events_match(original, restored), "%s/%s round-trips to an equal event" % [entry["action"], device] ) func test_event_from_dict_rejects_junk() -> void: # A save file from a newer build, or a hand-edited one, must degrade to # "unbound" rather than taking the game down before the player can reach # the Controls tab to fix it. assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event") assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event") assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event") assert_true( InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null, "a centred axis is not an event" ) func test_axis_bindings_are_distinguished_by_direction() -> void: # events_match must NOT collapse the two halves of one axis, or binding # pitch-up would silently unbind pitch-down as a "conflict". var up := InputEventJoypadMotion.new() up.axis = JOY_AXIS_RIGHT_Y up.axis_value = 1.0 var down := InputEventJoypadMotion.new() down.axis = JOY_AXIS_RIGHT_Y down.axis_value = -1.0 assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings") assert_true(InputSettings.events_match(up, up), "an axis binding matches itself") func test_set_binding_changes_the_live_input_map() -> void: var rebound := InputEventKey.new() rebound.physical_keycode = KEY_F # not used by any default binding InputSettings.set_binding("move_forward", rebound) var found := false for event in InputMap.action_get_events("move_forward"): if event is InputEventKey and event.physical_keycode == KEY_F: found = true assert_true(found, "the rebound key reaches InputMap") assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override") # The joypad half must survive a keyboard-only rebind. assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger") InputSettings.reset_all() func test_set_binding_displaces_the_conflicting_action() -> void: # Binding X to an input already in use must report and clear the previous # owner, not leave both bound and let the player wonder why two things fire. var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) assert_true(shared != null, "move_left has a keyboard binding to steal") if shared == null: return var displaced := InputSettings.set_binding("move_right", shared) assert_true(displaced.has("move_left"), "the displaced action is reported") assert_true( InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null, "the displaced action is actually unbound" ) InputSettings.reset_all() func test_reset_all_restores_the_project_defaults() -> void: var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) var rebound := InputEventJoypadButton.new() rebound.button_index = JOY_BUTTON_BACK InputSettings.set_binding("move_up", rebound) assert_true( InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before, "the rebind took effect" ) InputSettings.reset_all() assert_eq( InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD), before, "reset_all restores the default binding" ) assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override") func test_bindings_survive_a_save_and_reload() -> void: # The end-to-end persistence path, which nothing else covers: a rebind that # does not survive a restart is the single most visible way this feature can # fail, and it fails silently — the game runs fine, just on the defaults. # # This writes the real user://input.cfg, so the player's own file is saved # and put back. Restoring it is not optional: the test suite shares a # user:// directory with the game. var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH) var original := "" if had_file: original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH) var rebound := InputEventJoypadButton.new() rebound.button_index = JOY_BUTTON_BACK InputSettings.set_binding("turbo", rebound) InputSettings.invert_pitch = true InputSettings.save() # Drop the in-memory state the way a fresh launch would, then reload. InputSettings.reset_all() assert_true(not InputSettings.has_override("turbo"), "state cleared before reload") InputSettings._load() InputSettings.apply() assert_true(InputSettings.has_override("turbo"), "the override came back from disk") var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD) assert_true(loaded != null, "the reloaded binding is an event") if loaded != null: assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved") assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload") # A deliberately-cleared binding must stay cleared across a restart. This is # the case that distinguishes a real "unbound" record from an absent one: # ConfigFile.set_value() erases a key whose value is null, so a naive # implementation silently restores the default here instead. InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD) InputSettings.save() InputSettings.reset_all() InputSettings._load() InputSettings.apply() assert_true( InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null, "an unbound action stays unbound across a reload" ) assert_true( _joypad_events("roll_left").is_empty(), "the unbound action has no joypad event in InputMap after a reload" ) # And it must actually be live in InputMap, not merely remembered. var live := false for event in InputMap.action_get_events("turbo"): if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK: live = true assert_true(live, "the reloaded binding is applied to InputMap") InputSettings.reset_all() if had_file: var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE) if restore != null: restore.store_string(original) restore.close() InputSettings._load() InputSettings.apply() else: DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH)) func test_invert_pitch_drives_pitch_sign() -> void: var restore := InputSettings.invert_pitch InputSettings.invert_pitch = false assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign") InputSettings.invert_pitch = true assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign") InputSettings.invert_pitch = restore func test_every_default_binding_has_readable_text() -> void: # A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug # that no other assertion here would catch. for entry in InputSettings.ACTIONS: for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]: var text := InputSettings.binding_text(entry["action"], device) assert_true( text != "" and text != "Unbound", "%s/%s has a readable label (got %s)" % [entry["action"], device, text] ) func test_device_kind_of_rejects_events_it_cannot_bind() -> void: assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device") assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device") assert_eq( InputSettings.device_kind_of(InputEventJoypadMotion.new()), InputSettings.DEVICE_JOYPAD, "joypad motion device" )