extends GutTest ## Player preferences. Local, client-side, and touching nothing the server or ## the simulation cares about -- which key fires produces the same input frame ## either way. const SCRATCH := "user://test_settings_%d.cfg" var _saved_bindings: Dictionary[String, Dictionary] = {} var _saved_master: float = 0.0 var _saved_sfx: float = 0.0 var _scratch: String = "" func before_each() -> void: # Settings is static, so a test that changed it would leak into the next # one and into every other suite that reads the input map. _saved_bindings = Settings.bindings.duplicate() _saved_master = Settings.master_volume _saved_sfx = Settings.sfx_volume # And rebinding SAVES. Without redirecting the file, running the suite # rewrote the player's own settings -- it really did, and it took a # screenshot of the settings screen showing Fire on the right mouse button # to notice. _scratch = SCRATCH % randi() Settings.path = _scratch func after_each() -> void: Settings.bindings = _saved_bindings Settings.master_volume = _saved_master Settings.sfx_volume = _saved_sfx Settings.apply_input() Settings.path = Settings.DEFAULT_PATH DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch)) func _key_event(code: Key) -> InputEventKey: var e := InputEventKey.new() e.device = -1 e.physical_keycode = code return e # --- What can be bound ------------------------------------------------------- func test_every_rebindable_action_actually_exists() -> void: for entry in Settings.REBINDABLE: assert_true(InputMap.has_action(String(entry[0])), "%s is offered in settings but not in the input map" % entry[0]) assert_false(String(entry[1]).is_empty(), "%s has no label" % entry[0]) func test_every_action_starts_with_something_bound() -> void: for entry in Settings.REBINDABLE: assert_ne(Settings.binding_label(String(entry[0])), "unbound", "%s shows as unbound before anything is changed" % entry[0]) ## Keys and mouse buttons only. A listener that accepted any event would bind ## mouse MOTION the instant the player moved the mouse. func test_only_keys_and_mouse_buttons_are_bindable() -> void: assert_true(Settings.is_bindable(_key_event(KEY_J))) var click := InputEventMouseButton.new() click.button_index = MOUSE_BUTTON_RIGHT assert_true(Settings.is_bindable(click)) assert_false(Settings.is_bindable(InputEventMouseMotion.new())) assert_false(Settings.is_bindable(InputEventJoypadMotion.new())) assert_false(Settings.is_bindable(_key_event(KEY_NONE))) # --- Rebinding --------------------------------------------------------------- func test_rebinding_changes_what_the_input_map_answers_to() -> void: assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "", "an unused key should be accepted") assert_true(InputMap.action_has_event("fire", _key_event(KEY_J))) assert_eq(Settings.binding_label("fire"), Settings.describe_label( {"type": "key", "code": int(KEY_J)})) ## The old key must stop working. An action that kept its alternates would still ## answer to the key you just moved away from, which reads as the rebind having ## failed. func test_the_previous_key_stops_working() -> void: var before := Settings.current_binding("interact") Settings.rebind("interact", _key_event(KEY_J)) var old := InputEventKey.new() old.device = -1 old.physical_keycode = int(before["code"]) assert_false(InputMap.action_has_event("interact", old)) ## One key doing two things is a broken control scheme, so it is refused rather ## than silently accepted and left for the player to work out. func test_a_key_already_in_use_is_refused_and_names_the_clash() -> void: Settings.rebind("interact", _key_event(KEY_J)) assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "interact") assert_false(InputMap.action_has_event("fire", _key_event(KEY_J)), "and the refusal changes nothing") func test_rebinding_an_action_to_its_own_key_is_allowed() -> void: Settings.rebind("fire", _key_event(KEY_J)) assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "", "re-confirming a binding is not a clash with itself") func test_reset_restores_what_the_project_shipped() -> void: var original := Settings.binding_label("move_up") Settings.rebind("move_up", _key_event(KEY_J)) assert_ne(Settings.binding_label("move_up"), original) Settings.reset_bindings() assert_eq(Settings.binding_label("move_up"), original) assert_true(Settings.bindings.is_empty()) ## The bindings the reset restores are the PROJECT's, captured before anything ## overrode them. Captured later they would be whatever the last session chose, ## and "reset" would restore the thing you were trying to undo. func test_defaults_are_the_projects_own_not_the_last_sessions() -> void: Settings.rebind("move_left", _key_event(KEY_J)) Settings.apply_input() Settings._capture_defaults() # a second call must be a no-op Settings.reset_bindings() assert_ne(Settings.binding_label("move_left"), Settings.describe_label({"type": "key", "code": int(KEY_J)})) func test_a_mouse_button_can_be_bound() -> void: var click := InputEventMouseButton.new() click.device = -1 click.button_index = MOUSE_BUTTON_RIGHT assert_eq(Settings.rebind("fire", click), "") assert_eq(Settings.binding_label("fire"), "Mouse Right") # --- Persistence ------------------------------------------------------------- func test_settings_survive_a_save_and_reload() -> void: var path := SCRATCH % randi() var cfg := ConfigFile.new() cfg.set_value("audio", "master", 0.25) cfg.set_value("audio", "sfx", 0.5) cfg.set_value("input", "fire", {"type": "key", "code": int(KEY_J)}) cfg.save(path) var loaded := ConfigFile.new() assert_eq(loaded.load(path), OK) assert_almost_eq(float(loaded.get_value("audio", "master")), 0.25, 0.001) var described: Dictionary = loaded.get_value("input", "fire") assert_eq(Settings.describe_label(described), Settings.describe_label({"type": "key", "code": int(KEY_J)})) DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) ## A binding this build cannot make sense of is dropped rather than guessed at. ## An unusable control is worse than the default one. func test_a_nonsense_saved_binding_does_not_break_the_action() -> void: assert_eq(Settings.describe_label({}), "unbound") assert_eq(Settings.describe_label({"type": "gamepad", "code": 3}), "unbound") assert_eq(Settings.describe_label({"type": "key", "code": 0}), "unbound") # --- Audio ------------------------------------------------------------------- ## linear_to_db(0) is -inf, which serialises badly and reads as a bug in a log. ## Silence is the mute flag; this only has to floor well below audible. func test_silence_is_a_finite_number() -> void: var quiet := Settings.linear_to_db_clamped(0.0) assert_true(is_finite(quiet)) assert_lt(quiet, -60.0) func test_full_volume_is_unattenuated() -> void: assert_almost_eq(Settings.linear_to_db_clamped(1.0), 0.0, 0.01) func test_volume_is_monotonic() -> void: var last := -999.0 for step in 11: var db := Settings.linear_to_db_clamped(float(step) / 10.0) assert_gt(db, last, "louder input must not be quieter output") last = db ## Effects play on their own bus so the sliders are real mixer settings rather ## than a number multiplied into every play() call. func test_applying_audio_creates_the_effects_bus() -> void: Settings.apply_audio() var index := AudioServer.get_bus_index(Settings.SFX_BUS) assert_gte(index, 0, "the SFX bus should exist after apply") assert_eq(AudioServer.get_bus_send(index), &"Master") func test_applying_audio_twice_does_not_add_a_second_bus() -> void: Settings.apply_audio() var before := AudioServer.bus_count Settings.apply_audio() assert_eq(AudioServer.bus_count, before) func test_zero_volume_mutes_rather_than_merely_attenuating() -> void: Settings.master_volume = 0.0 Settings.apply_audio() assert_true(AudioServer.is_bus_mute(0)) Settings.master_volume = 0.8 Settings.apply_audio() assert_false(AudioServer.is_bus_mute(0)) ## A corrupt or hand-edited file can hold anything. Every one of these has to ## come out as a real number the mixer will accept. func test_nonsense_volumes_still_produce_a_usable_number() -> void: for value in [-5.0, -0.0001, 0.0, 1.5, 1e9]: var db := Settings.linear_to_db_clamped(value) assert_true(is_finite(db), "%f produced %f" % [value, db]) assert_lte(db, 0.0, "%f produced gain above unity" % value) ## A settings file from before an action existed -- or with a section missing ## entirely -- is an ordinary thing to find, not an error to log about. Reading ## it must be silent. func test_a_settings_file_missing_keys_loads_without_complaint() -> void: var path := SCRATCH % randi() var cfg := ConfigFile.new() cfg.set_value("audio", "master", 0.4) # audio only, no input section cfg.save(path) var loaded := ConfigFile.new() assert_eq(loaded.load(path), OK) for entry in Settings.REBINDABLE: assert_false(loaded.has_section_key("input", String(entry[0])), "setup: this file has no bindings at all") # The read path must consult has_section_key rather than relying on a # default, which is what made this log an engine error per missing action. assert_false(loaded.has_section_key("input", "move_up")) DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) ## The redirect above is a test fixture. If it were ever left in place -- or the ## default changed to something under a scratch directory -- players would ## silently stop keeping their settings. func test_the_default_path_is_the_players_own_file() -> void: assert_eq(Settings.DEFAULT_PATH, "user://settings.cfg") assert_true(Settings.DEFAULT_PATH.begins_with("user://")) ## Saving has to actually reach the file the path points at, or the redirect ## above would hide a broken save rather than isolate a working one. func test_saving_writes_to_the_configured_path() -> void: Settings.master_volume = 0.33 Settings.save() assert_true(FileAccess.file_exists(_scratch), "nothing was written") var cfg := ConfigFile.new() assert_eq(cfg.load(_scratch), OK) assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001) # --- The connect screen remembers --------------------------------------------- func test_the_last_server_survives_a_save_and_reload() -> void: var saved_address := Settings.last_address var saved_port := Settings.last_port Settings.remember_server(" play.example.net ", 27020) Settings.last_address = "" Settings.last_port = 0 Settings.load_and_apply() assert_eq(Settings.last_address, "play.example.net", "trimmed, and back again") assert_eq(Settings.last_port, 27020) Settings.last_address = saved_address Settings.last_port = saved_port func test_nonsense_is_not_remembered() -> void: var saved_address := Settings.last_address Settings.last_address = "kept.example.net" Settings.remember_server(" ", 27020) Settings.remember_server("x.example.net", 0) Settings.remember_server("x.example.net", 70000) assert_eq(Settings.last_address, "kept.example.net", "a blank address or an impossible port must not replace a good one") Settings.last_address = saved_address # --- The HUD says what the keys actually are ---------------------------------- # Every hint on screen used to be a literal -- "WASD move", "LMB fire", "hold F" # -- while every one of those actions is rebindable. Rebind fire and the HUD # kept telling you to click. const HUD := preload("res://src/ui/hud.gd") func test_the_hints_name_the_default_keys() -> void: assert_eq(HUD.move_keys(), "WASD") assert_eq(HUD.key("fire"), "LMB") assert_eq(HUD.slot_keys(4), "1-4") assert_string_contains(HUD.controls_hint(false), "hold F") assert_string_contains(HUD.controls_hint(false), "Esc menu", "spelled the way a keycap spells it, not the way the OS names it") func test_the_hints_follow_a_rebind() -> void: var click := InputEventMouseButton.new() click.button_index = MOUSE_BUTTON_RIGHT click.device = -1 assert_eq(Settings.rebind("fire", click), "") assert_eq(Settings.rebind("emergency_escape", _key_event(KEY_G)), "") var hint := HUD.controls_hint(false) assert_string_contains(hint, "RMB fire") assert_string_contains(hint, "hold G") assert_false(hint.contains("LMB"), "the old key is not mentioned anywhere") func test_movement_spelled_out_when_it_is_not_four_letters() -> void: assert_eq(Settings.rebind("move_up", _key_event(KEY_UP)), "") var keys := HUD.move_keys() assert_true(keys.begins_with("Up/"), "arrow keys read as words, got %s" % keys) func test_slot_keys_are_listed_when_they_are_not_one_to_four() -> void: assert_eq(Settings.rebind("use_slot_3", _key_event(KEY_Z)), "") assert_eq(HUD.slot_keys(4), "1 2 Z 4") func test_the_upgrade_screen_names_the_key_that_closes_it() -> void: assert_eq(Settings.rebind("interact", _key_event(KEY_G)), "") var screen: CanvasLayer = preload("res://src/ui/upgrade_screen.gd").new() add_child_autofree(screen) var none: Array[StringName] = [] screen.refresh(0, none, none) assert_eq(screen._close.text, "Close (G)", "it used to say (E) whatever interact was bound to") func test_short_labels_are_what_a_keycap_says() -> void: var click := InputEventMouseButton.new() click.button_index = MOUSE_BUTTON_LEFT click.device = -1 assert_eq(Settings.rebind("interact", click), "fire", "left click is fire's, and the clash is named") assert_eq(Settings.short_label("fire"), "LMB") assert_eq(Settings.short_label("system_menu"), "Esc")