Files
transcience/tests/unit/test_settings.gd
T
claudeandClaude Opus 5.5 19d2295bec
ci / verify (push) Successful in 53s
A UI pass: prompts that tell the truth, and a HUD that does not collide
Prompts named the default keys whatever was bound. Every action is
rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4
use", the quartermaster said "E talk" and the upgrade screen "Close (E)".
All of them now go through Settings.short_label(), and a test rebinds
fire, escape, interact and a slot key and checks each prompt follows.

The HUD's top-left was a developer's status line -- name, instance id,
health as text, frame rate -- with the boss bar drawn over its right end
and the level-up notice sharing a row with the escape bar. It is now
name and level, where you are, health with its numbers, experience,
escape and level-ups, stacked by a running cursor so nothing has a fixed
row to collide on. Instance and fps moved behind F1, which is who they
were for. Arrival protection is a centred banner rather than a note
beside the health bar, because it is the one moment the rules change.

A hit used to tint the whole screen red, which in a bullet hell dims the
bullets at exactly the moment you are among them. It is an edge now, and
the same edge pulses below a third of your health. New: a message feed
above the inventory for what the server just confirmed -- items taken,
used and dropped, levels gained -- and party health top-right in a
dungeon, where the hub shows who is online. The pickup prompt now also
names the dungeon under your feet when you stand on its ring, in the
same order the server resolves that key: loot first, then the entrance.

The boss bar used to announce the boss to anyone within the actor
interest radius -- which passes through walls -- so a player in the
entrance room was told the Warden was near. It waits until the fog has
actually lit the boss once, then stays for the fight.

Permadeath was announced only by the roster screen appearing. The client
records its own death from the server's event and the roster says who
fell, at what level, and where. The connect screen lost a name field
that did nothing for a person -- it only named the characters bots
create -- gained a proper panel, remembers the last server, and shows
the protocol number a mismatch refusal will quote.

And screenshot.tscn ran as the player's real account against their real
character store, walking their character into dungeons for pictures --
with health now carried between runs, a stray hit would have stuck. It
has its own store and account now, like every diagnostic, and a seeded
character. The real store was checked: nothing it carries changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:04 +02:00

343 lines
13 KiB
GDScript

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")