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)