diff --git a/common/ini_file.gd b/common/ini_file.gd new file mode 100644 index 0000000..008b78b --- /dev/null +++ b/common/ini_file.gd @@ -0,0 +1,73 @@ +class_name IniFile +extends RefCounted +## A small INI file for settings people edit by hand: [code][section][/code] headers and +## [code]key = value[/code] lines. +## +## Lines starting with [code];[/code] or [code]#[/code] are comments. Comments take whole +## lines, so a value such as a password can hold any character. Quotes around a value are +## optional and removed. Section and key names are not case-sensitive. + +const TRUE_WORDS: Array[String] = ["true", "yes", "on", "1"] +const FALSE_WORDS: Array[String] = ["false", "no", "off", "0"] + +var _sections: Dictionary[String, Dictionary] = {} + + +## Reads INI [param text]. Lines that are neither a section, a setting nor a comment are +## skipped with a warning. +static func parse(text: String) -> IniFile: + var ini: IniFile = IniFile.new() + var section: String = "" + var line_number: int = 0 + for raw_line: String in text.split("\n"): + line_number += 1 + var line: String = raw_line.strip_edges() + if line.is_empty() or line.begins_with(";") or line.begins_with("#"): + continue + if line.begins_with("[") and line.ends_with("]"): + section = line.substr(1, line.length() - 2).strip_edges().to_lower() + continue + var equals: int = line.find("=") + if equals <= 0: + push_warning("Line %d is not a setting: %s" % [line_number, line]) + continue + var key: String = line.substr(0, equals).strip_edges().to_lower() + var value: String = line.substr(equals + 1).strip_edges() + if value.length() >= 2 and value[0] in ["\"", "'"] and value[-1] == value[0]: + value = value.substr(1, value.length() - 2) + if !ini._sections.has(section): + ini._sections[section] = {} + ini._sections[section][key] = value + return ini + + +## Reads the INI file at [param path]. A missing file gives an empty [IniFile]. +static func load_file(path: String) -> IniFile: + return parse(FileAccess.get_file_as_string(path)) + + +func has_value(section: String, key: String) -> bool: + return _sections.get(section.to_lower(), {}).has(key.to_lower()) + + +## The sections that have at least one setting. +func get_sections() -> Array[String]: + return _sections.keys() + + +func get_string(section: String, key: String, default: String = "") -> String: + return _sections.get(section.to_lower(), {}).get(key.to_lower(), default) + + +## Reads true/false, yes/no, on/off or 1/0. Anything else gives [param default] and a +## warning. +func get_bool(section: String, key: String, default: bool) -> bool: + if !has_value(section, key): + return default + var value: String = get_string(section, key).to_lower() + if value in TRUE_WORDS: + return true + if value in FALSE_WORDS: + return false + push_warning("%s in [%s] should be true or false, not %s" % [key, section, value]) + return default diff --git a/common/ini_file.gd.uid b/common/ini_file.gd.uid new file mode 100644 index 0000000..bea18f2 --- /dev/null +++ b/common/ini_file.gd.uid @@ -0,0 +1 @@ +uid://b262vckl8hbn4 diff --git a/data/settings.gd b/data/settings.gd index bc4c7cd..736e7bf 100644 --- a/data/settings.gd +++ b/data/settings.gd @@ -8,31 +8,57 @@ const MAX_NUMBER_OF_PLAYERS: int = 7 ## but apart, e.g. "lttp" for "A Link to the Past". Shorter searches only find names ## that contain the text, since a letter or two in order matches most of the library. const LETTERS_FOR_LOOSE_SEARCH: int = 3 -## The MusicServers that can be picked in the settings. +## The MusicServers that can always be picked in the settings. const SERVERS: Array[String] = [ "https://music.sanplex.xyz", "https://tmusic.sanplex.xyz", "https://rmusic.sanplex.xyz", "https://old-music.sanplex.xyz", "http://localhost:8080", ] -## The server debug builds use. +## The server debug mode uses. const DEBUG_SERVER: String = "http://localhost:8080" -## The winning score debug builds start with, so a game is quick to win while testing. +## The winning score debug mode starts with, so a game is quick to win while testing. const DEBUG_WINNING_SCORE: int = 5 -## The quiz settings that are saved to [method quiz_settings_path] and loaded at start. +## The settings the game saves by itself, by section, in [method saved_settings_path]. ## Count plays in statistics and Prefer less played soundtracks are left out on purpose. -const QUIZ_SETTINGS: Array[StringName] = [ - &"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning", - &"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score", - &"inspiration_list_speed", -] -## When this environment variable is set, the settings file is kept in that folder instead -## of the user's config folder. The tests set it so they never touch the real file. +const SAVED_SETTINGS: Dictionary[String, Array] = { + "quiz": [ + &"auto_repeat_song", &"stop_after_current", &"hide_next_track", &"hide_beginning", + &"hide_for_seconds", &"hide_length", &"hide_ticks", &"winning_score", + &"inspiration_list_speed", + ], + "window": [&"fullscreen"], +} +## When this environment variable is set, the settings files are kept in that folder +## instead of the user's config folder. The tests set it so they never touch the real files. const CONFIG_DIR_ENV: String = "MUSICPLAYER_CONFIG_DIR" -const QUIZ_SETTINGS_SECTION: String = "quiz" +## The file the user edits by hand. The game writes it only when it is missing, with every +## setting commented out. +const CONFIG_FILE_NAME: String = "config.ini" +const CONFIG_TEMPLATE: String = """# MusicPlayer config. The game never changes this file, so edit it as you like. +# Lines starting with # or ; are comments. Remove the # in front of a setting to use it, +# and put it back to go back to the default. + +[server] +# The MusicServer to use when the game starts. Another one can still be picked in +# Settings for as long as the game runs. Debug mode uses http://localhost:8080. +#url = https://music.sanplex.xyz + +# Password for getting an API token from the server. +#password = + +[game] +# true or false. Debug mode uses the local server, starts with a winning score of 5 and +# shows DEBUG in the top bar. Without this setting, the game is in debug mode when run +# from the Godot editor and not when exported. +#debug = true +""" var default_path: String = "https://music.sanplex.xyz" ## Password for getting an API token from the server. Any password works when the ## server has none set. var api_password: String = "" +## The servers that can be picked in Settings: [constant SERVERS], plus the server from +## the config file when it is not one of them. +var servers: Array[String] = SERVERS.duplicate() var selected_server: int = 0 var is_local: bool = false var is_debug: bool = false @@ -59,53 +85,108 @@ var character_select_open: bool = false var version: String = ProjectSettings.get_setting("application/config/version", "") -# Autoloads are ready before any scene, so debug builds use the local server and the -# low winning score from the very first request. The saved quiz settings come after, so -# the debug winning score is only used until another one is saved. +# Autoloads are ready before any scene, so the server and debug mode from the config file +# are used from the very first request. The saved settings come after, so the debug +# winning score is only used until another one is saved. func _ready() -> void: - if OS.has_feature("debug"): - is_debug = true - default_path = DEBUG_SERVER - selected_server = SERVERS.find(DEBUG_SERVER) + apply_config(load_config()) + load_saved_settings() + if fullscreen: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + + +## Uses the server, password and debug mode from the hand-edited [param config]. Settings +## left out of it get their defaults. +func apply_config(config: IniFile) -> void: + is_debug = config.get_bool("game", "debug", OS.has_feature("debug")) + default_path = DEBUG_SERVER if is_debug else SERVERS[0] + if is_debug: winning_score = DEBUG_WINNING_SCORE - load_quiz_settings() + var url: String = config.get_string("server", "url") + if !url.is_empty(): + default_path = url.trim_suffix("/") + if !servers.has(default_path): + servers.append(default_path) + selected_server = servers.find(default_path) + api_password = config.get_string("server", "password") -## Where the quiz settings are saved: a MusicPlayer folder in the user's config folder, -## e.g. [code]~/.config[/code] on Linux and [code]%APPDATA%[/code] on Windows. Debug -## builds use a file of their own, so testing never changes the real settings. -func quiz_settings_path() -> String: - var config_dir: String = OS.get_environment(CONFIG_DIR_ENV) - if config_dir.is_empty(): - config_dir = OS.get_config_dir().path_join("MusicPlayer") - var file_name: String = "quiz_settings_debug.cfg" if is_debug else "quiz_settings.cfg" - return config_dir.path_join(file_name) +## The folder with the settings files: a MusicPlayer folder in the user's config folder, +## e.g. [code]~/.config[/code] on Linux and [code]%APPDATA%[/code] on Windows. +func config_dir() -> String: + var dir: String = OS.get_environment(CONFIG_DIR_ENV) + if dir.is_empty(): + dir = OS.get_config_dir().path_join("MusicPlayer") + return dir -## Loads the saved quiz settings. Settings missing from the file, or saved with the +## Where the hand-edited config file is. +func config_path() -> String: + return config_dir().path_join(CONFIG_FILE_NAME) + + +## Where the game saves its settings. Debug mode uses a file of its own, so testing never +## changes the real settings. +func saved_settings_path() -> String: + var file_name: String = "settings_debug.cfg" if is_debug else "settings.cfg" + return config_dir().path_join(file_name) + + +## Reads the hand-edited config file. When it is missing, writes one with every setting +## commented out, so there is a file to edit. +func load_config() -> IniFile: + var path: String = config_path() + if !FileAccess.file_exists(path): + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + var template: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if template != null: + template.store_string(CONFIG_TEMPLATE) + return IniFile.load_file(path) + + +## Loads the settings the game saved. Settings missing from the file, or saved with the ## wrong type, keep their current value. -func load_quiz_settings() -> void: +func load_saved_settings() -> void: var file: ConfigFile = ConfigFile.new() - if file.load(quiz_settings_path()) != OK: + if file.load(saved_settings_path()) != OK: return - for setting: StringName in QUIZ_SETTINGS: - var current: Variant = get(setting) - var saved: Variant = file.get_value(QUIZ_SETTINGS_SECTION, setting, current) - if typeof(saved) == typeof(current): - set(setting, saved) + for section: String in SAVED_SETTINGS: + for setting: StringName in SAVED_SETTINGS[section]: + set(setting, _saved_value(file, section, setting, get(setting))) -## Saves the quiz settings, so they are the same the next time the game starts. -func save_quiz_settings() -> void: - var path: String = quiz_settings_path() +## Saves the settings in [constant SAVED_SETTINGS], so they are the same the next time +## the game starts. +func save_settings() -> void: + var path: String = saved_settings_path() DirAccess.make_dir_recursive_absolute(path.get_base_dir()) var file: ConfigFile = ConfigFile.new() - for setting: StringName in QUIZ_SETTINGS: - file.set_value(QUIZ_SETTINGS_SECTION, setting, get(setting)) + for section: String in SAVED_SETTINGS: + for setting: StringName in SAVED_SETTINGS[section]: + file.set_value(section, setting, get(setting)) var error: Error = file.save(path) if error != OK: - GameLog.warning("Could not save the quiz settings to %s: %s" - % [path, error_string(error)]) + GameLog.warning("Could not save the settings to %s: %s" % [path, error_string(error)]) + + +## Turns fullscreen on or off and saves it. +func set_fullscreen(on: bool) -> void: + fullscreen = on + if on: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + else: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + save_settings() + + +# The saved value of [param key], or [param default] when it is missing or of another type +func _saved_value(file: ConfigFile, section: String, key: String, default: Variant) -> Variant: + var value: Variant = file.get_value(section, key, default) + if typeof(value) != typeof(default): + push_warning("%s in [%s] should be a %s, not %s" % [key, section, + type_string(typeof(default)), var_to_str(value)]) + return default + return value func delete_children(node: Node) -> void: diff --git a/main/main_window.gd b/main/main_window.gd index 6786a09..accb526 100644 --- a/main/main_window.gd +++ b/main/main_window.gd @@ -48,11 +48,7 @@ func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed: if event.alt_pressed && event.keycode == KEY_ENTER: GameLog.debug("Alt + Enter pressed") - Settings.fullscreen = !Settings.fullscreen - if Settings.fullscreen: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - if !Settings.fullscreen: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + Settings.set_fullscreen(!Settings.fullscreen) if event.alt_pressed && event.keycode == KEY_K: shortcuts_popup.toggle() if event.alt_pressed && event.keycode == KEY_L: diff --git a/popups/settings_popup.gd b/popups/settings_popup.gd index 5593769..f248822 100644 --- a/popups/settings_popup.gd +++ b/popups/settings_popup.gd @@ -21,7 +21,7 @@ signal server_changed func _ready() -> void: - for server: String in Settings.SERVERS: + for server: String in Settings.servers: select_server_button.add_item(server) fullscreen_button.button_pressed = Settings.fullscreen select_server_button.select(Settings.selected_server) @@ -41,11 +41,7 @@ func _ready() -> void: func fullscreen() -> void: - Settings.fullscreen = !Settings.fullscreen - if Settings.fullscreen: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - if !Settings.fullscreen: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + Settings.set_fullscreen(!Settings.fullscreen) func local_play() -> void: @@ -75,7 +71,7 @@ func set_selected_server(new_server: int) -> void: func _use_server(new_server: int) -> void: - Settings.default_path = Settings.SERVERS[new_server] + Settings.default_path = Settings.servers[new_server] Settings.selected_server = new_server GameLog.debug("Settings.default_path: " + Settings.default_path) server_changed.emit() diff --git a/quiz/quiz_settings_card.gd b/quiz/quiz_settings_card.gd index 9df28b2..1649372 100644 --- a/quiz/quiz_settings_card.gd +++ b/quiz/quiz_settings_card.gd @@ -4,7 +4,7 @@ extends BasePopup ## music player: how songs are played and hidden, the winning score and the inspiration ## list. It does not dim the quiz, so it can be changed during a song. ## -## Every change is saved at once with [method Settings.save_quiz_settings]. +## Every change is saved at once with [method Settings.save_settings]. ## ## The card's bottom edge is where the card is placed, just above the player bar, and ## it grows upward to fit its rows. @@ -81,37 +81,37 @@ func increase_winning_score() -> void: func set_winning_score(new_score: int) -> void: Settings.winning_score = maxi(new_score, 1) score_label.text = str(Settings.winning_score) - Settings.save_quiz_settings() + Settings.save_settings() func lower_inspiration_speed() -> void: Settings.inspiration_list_speed = maxi(Settings.inspiration_list_speed - 1, 1) inspiration_speed_label.text = str(Settings.inspiration_list_speed) - Settings.save_quiz_settings() + Settings.save_settings() func increase_inspiration_speed() -> void: Settings.inspiration_list_speed += 1 inspiration_speed_label.text = str(Settings.inspiration_list_speed) - Settings.save_quiz_settings() + Settings.save_settings() func lower_hide_for_seconds() -> void: Settings.hide_for_seconds = maxf(Settings.hide_for_seconds - 1.0, 0.0) hide_for_seconds_label.text = "%d" % Settings.hide_for_seconds - Settings.save_quiz_settings() + Settings.save_settings() func increase_hide_for_seconds() -> void: Settings.hide_for_seconds = minf(Settings.hide_for_seconds + 1.0, MAX_HIDE_SECONDS) hide_for_seconds_label.text = "%d" % Settings.hide_for_seconds - Settings.save_quiz_settings() + Settings.save_settings() # Sets the on/off quiz setting named [param setting] and saves the quiz settings func _save_toggle(on: bool, setting: StringName) -> void: Settings.set(setting, on) - Settings.save_quiz_settings() + Settings.save_settings() func _on_hide_beginning_toggled(on: bool) -> void: diff --git a/tests/integration/test_quiz_settings_card.gd b/tests/integration/test_quiz_settings_card.gd index be5ef68..b2b4239 100644 --- a/tests/integration/test_quiz_settings_card.gd +++ b/tests/integration/test_quiz_settings_card.gd @@ -174,6 +174,6 @@ func test_changes_are_saved_at_once() -> void: _card.lower_winning_score_button.pressed.emit() var saved: ConfigFile = ConfigFile.new() - assert_int(saved.load(Settings.quiz_settings_path())).is_equal(OK) + assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK) assert_bool(saved.get_value("quiz", "hide_next_track")).is_false() assert_int(saved.get_value("quiz", "winning_score")).is_equal(19) diff --git a/tests/integration/test_settings_popup.gd b/tests/integration/test_settings_popup.gd index fe9d3d5..1039e6c 100644 --- a/tests/integration/test_settings_popup.gd +++ b/tests/integration/test_settings_popup.gd @@ -35,10 +35,10 @@ func test_statistics_settings_are_not_saved_with_the_quiz_settings() -> void: Settings.add_to_stats = false Settings.use_low_played_mode = true - Settings.save_quiz_settings() + Settings.save_settings() var saved: ConfigFile = ConfigFile.new() - assert_int(saved.load(Settings.quiz_settings_path())).is_equal(OK) + assert_int(saved.load(Settings.saved_settings_path())).is_equal(OK) assert_bool(saved.has_section_key("quiz", "add_to_stats")).is_false() assert_bool(saved.has_section_key("quiz", "use_low_played_mode")).is_false() diff --git a/tests/unit/test_ini_file.gd b/tests/unit/test_ini_file.gd new file mode 100644 index 0000000..61ff033 --- /dev/null +++ b/tests/unit/test_ini_file.gd @@ -0,0 +1,74 @@ +extends GdUnitTestSuite +## The small INI reader used for the hand-edited config file. + + +func test_reads_sections_and_settings() -> void: + var ini: IniFile = IniFile.parse("[server]\nurl = http://a:8080\n[game]\ndebug=true\n") + + assert_str(ini.get_string("server", "url")).is_equal("http://a:8080") + assert_bool(ini.get_bool("game", "debug", false)).is_true() + assert_array(ini.get_sections()).contains_exactly(["server", "game"]) + + +func test_comment_lines_are_skipped() -> void: + var ini: IniFile = IniFile.parse("; one\n# two\n[server]\n;url = a\n # url = b\n") + + assert_bool(ini.has_value("server", "url")).is_false() + assert_array(ini.get_sections()).is_empty() + + +func test_values_keep_comment_characters() -> void: + var ini: IniFile = IniFile.parse("[server]\npassword = a;b#c=d\n") + + assert_str(ini.get_string("server", "password")).is_equal("a;b#c=d") + + +func test_quotes_around_a_value_are_removed() -> void: + var ini: IniFile = IniFile.parse("[a]\none = \"x y\"\ntwo = 'z'\nthree = \"half\n") + + assert_str(ini.get_string("a", "one")).is_equal("x y") + assert_str(ini.get_string("a", "two")).is_equal("z") + assert_str(ini.get_string("a", "three")).is_equal("\"half") + + +func test_names_are_not_case_sensitive() -> void: + var ini: IniFile = IniFile.parse("[Server]\nURL = http://a\n") + + assert_str(ini.get_string("server", "url")).is_equal("http://a") + + +func test_empty_value_is_an_empty_string() -> void: + var ini: IniFile = IniFile.parse("[server]\npassword =\n") + + assert_bool(ini.has_value("server", "password")).is_true() + assert_str(ini.get_string("server", "password", "default")).is_empty() + + +func test_missing_setting_gives_the_default() -> void: + var ini: IniFile = IniFile.parse("") + + assert_str(ini.get_string("server", "url", "fallback")).is_equal("fallback") + assert_bool(ini.get_bool("game", "debug", true)).is_true() + + +func test_bool_words() -> void: + var ini: IniFile = IniFile.parse("[a]\nb = Yes\nc = off\nd = 1\ne = maybe\n") + + assert_bool(ini.get_bool("a", "b", false)).is_true() + assert_bool(ini.get_bool("a", "c", true)).is_false() + assert_bool(ini.get_bool("a", "d", false)).is_true() + assert_bool(ini.get_bool("a", "e", false)).is_false() + assert_bool(ini.get_bool("a", "e", true)).is_true() + + +func test_windows_line_endings() -> void: + var ini: IniFile = IniFile.parse("[server]\r\nurl = http://a\r\n") + + assert_str(ini.get_string("server", "url")).is_equal("http://a") + + +func test_lines_that_are_not_settings_are_skipped() -> void: + var ini: IniFile = IniFile.parse("[a]\njust words\n= no key\nkey = value\n") + + assert_str(ini.get_string("a", "key")).is_equal("value") + assert_bool(ini.has_value("a", "just words")).is_false() diff --git a/tests/unit/test_ini_file.gd.uid b/tests/unit/test_ini_file.gd.uid new file mode 100644 index 0000000..58dab03 --- /dev/null +++ b/tests/unit/test_ini_file.gd.uid @@ -0,0 +1 @@ +uid://cvc8j1xrxq8uo diff --git a/tests/unit/test_quiz_settings_file.gd b/tests/unit/test_quiz_settings_file.gd deleted file mode 100644 index b105d62..0000000 --- a/tests/unit/test_quiz_settings_file.gd +++ /dev/null @@ -1,97 +0,0 @@ -extends GdUnitTestSuite -## Saving the quiz settings to a file in the config folder and loading them back. - -const SAVED_VALUES: Dictionary = { - &"auto_repeat_song": false, &"stop_after_current": false, &"hide_next_track": false, - &"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true, - &"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4, -} - -var _snapshot: AutoloadSnapshot -var _config_dir_before: String -var _config_dir: String - - -func before_test() -> void: - _snapshot = AutoloadSnapshot.take([Settings]) - _config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV) - _config_dir = create_temp_dir("quiz_settings_file") - OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir) - DirAccess.remove_absolute(Settings.quiz_settings_path()) - - -func after_test() -> void: - OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before) - _snapshot.restore() - - -func _set_settings(values: Dictionary) -> void: - for setting: StringName in values: - Settings.set(setting, values[setting]) - - -func test_saved_settings_load_back() -> void: - _set_settings(SAVED_VALUES) - Settings.save_quiz_settings() - _snapshot.restore() - - Settings.load_quiz_settings() - - for setting: StringName in SAVED_VALUES: - assert_that(Settings.get(setting)).override_failure_message(setting) \ - .is_equal(SAVED_VALUES[setting]) - - -func test_every_quiz_setting_is_saved() -> void: - assert_array(Settings.QUIZ_SETTINGS).contains_exactly_in_any_order(SAVED_VALUES.keys()) - - -func test_missing_file_keeps_the_current_settings() -> void: - Settings.winning_score = 9 - - Settings.load_quiz_settings() - - assert_int(Settings.winning_score).is_equal(9) - - -func test_value_of_the_wrong_type_is_ignored() -> void: - var file: ConfigFile = ConfigFile.new() - file.set_value("quiz", "winning_score", "lots") - file.set_value("quiz", "hide_ticks", true) - DirAccess.make_dir_recursive_absolute(Settings.quiz_settings_path().get_base_dir()) - file.save(Settings.quiz_settings_path()) - Settings.winning_score = 9 - Settings.hide_ticks = false - - Settings.load_quiz_settings() - - assert_int(Settings.winning_score).is_equal(9) - assert_bool(Settings.hide_ticks).is_true() - - -func test_statistics_settings_are_not_saved() -> void: - Settings.save_quiz_settings() - - var file: ConfigFile = ConfigFile.new() - file.load(Settings.quiz_settings_path()) - assert_bool(file.has_section_key("quiz", "add_to_stats")).is_false() - assert_bool(file.has_section_key("quiz", "use_low_played_mode")).is_false() - - -func test_file_is_in_the_folder_from_the_environment() -> void: - assert_str(Settings.quiz_settings_path().get_base_dir()).is_equal(_config_dir) - - -func test_file_is_in_the_config_folder_without_the_environment() -> void: - OS.unset_environment(Settings.CONFIG_DIR_ENV) - - assert_str(Settings.quiz_settings_path().get_base_dir()) \ - .is_equal(OS.get_config_dir().path_join("MusicPlayer")) - - -func test_debug_builds_use_a_file_of_their_own() -> void: - Settings.is_debug = true - var debug_path: String = Settings.quiz_settings_path() - Settings.is_debug = false - - assert_str(debug_path).is_not_equal(Settings.quiz_settings_path()) diff --git a/tests/unit/test_settings_files.gd b/tests/unit/test_settings_files.gd new file mode 100644 index 0000000..d2e6007 --- /dev/null +++ b/tests/unit/test_settings_files.gd @@ -0,0 +1,203 @@ +extends GdUnitTestSuite +## The settings files in the config folder: the settings the game saves by itself, and the +## config file with the server, password and debug mode that the user edits by hand. + +const SAVED_VALUES: Dictionary = { + &"auto_repeat_song": false, &"stop_after_current": false, &"hide_next_track": false, + &"hide_beginning": true, &"hide_for_seconds": 7.0, &"hide_length": true, + &"hide_ticks": true, &"winning_score": 13, &"inspiration_list_speed": 4, + &"fullscreen": true, +} + +var _snapshot: AutoloadSnapshot +var _config_dir_before: String +var _config_dir: String + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings]) + _config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV) + _config_dir = create_temp_dir("settings_files") + OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir) + DirAccess.remove_absolute(Settings.saved_settings_path()) + DirAccess.remove_absolute(Settings.config_path()) + + +func after_test() -> void: + OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before) + _snapshot.restore() + + +func _set_settings(values: Dictionary) -> void: + for setting: StringName in values: + Settings.set(setting, values[setting]) + + +func _write_config(text: String) -> void: + var file: FileAccess = FileAccess.open(Settings.config_path(), FileAccess.WRITE) + file.store_string(text) + file.close() + + +func _saved_file() -> ConfigFile: + var file: ConfigFile = ConfigFile.new() + assert_int(file.load(Settings.saved_settings_path())).is_equal(OK) + return file + + +func test_saved_settings_load_back() -> void: + _set_settings(SAVED_VALUES) + Settings.save_settings() + _snapshot.restore() + + Settings.load_saved_settings() + + for setting: StringName in SAVED_VALUES: + assert_that(Settings.get(setting)).override_failure_message(setting) \ + .is_equal(SAVED_VALUES[setting]) + + +func test_every_saved_setting_is_tested() -> void: + var saved: Array = [] + for section: String in Settings.SAVED_SETTINGS: + saved.append_array(Settings.SAVED_SETTINGS[section]) + + assert_array(saved).contains_exactly_in_any_order(SAVED_VALUES.keys()) + + +func test_missing_file_keeps_the_current_settings() -> void: + Settings.winning_score = 9 + + Settings.load_saved_settings() + + assert_int(Settings.winning_score).is_equal(9) + + +func test_value_of_the_wrong_type_is_ignored() -> void: + var file: ConfigFile = ConfigFile.new() + file.set_value("quiz", "winning_score", "lots") + file.set_value("quiz", "hide_ticks", true) + file.save(Settings.saved_settings_path()) + Settings.winning_score = 9 + Settings.hide_ticks = false + + Settings.load_saved_settings() + + assert_int(Settings.winning_score).is_equal(9) + assert_bool(Settings.hide_ticks).is_true() + + +func test_statistics_settings_are_not_saved() -> void: + Settings.save_settings() + + var file: ConfigFile = _saved_file() + assert_bool(file.has_section_key("quiz", "add_to_stats")).is_false() + assert_bool(file.has_section_key("quiz", "use_low_played_mode")).is_false() + + +func test_fullscreen_is_saved_when_it_changes() -> void: + Settings.set_fullscreen(true) + assert_bool(_saved_file().get_value("window", "fullscreen")).is_true() + + Settings.set_fullscreen(false) + assert_bool(_saved_file().get_value("window", "fullscreen")).is_false() + + +func test_files_are_in_the_folder_from_the_environment() -> void: + assert_str(Settings.saved_settings_path().get_base_dir()).is_equal(_config_dir) + assert_str(Settings.config_path().get_base_dir()).is_equal(_config_dir) + + +func test_files_are_in_the_config_folder_without_the_environment() -> void: + OS.unset_environment(Settings.CONFIG_DIR_ENV) + + assert_str(Settings.config_dir()).is_equal(OS.get_config_dir().path_join("MusicPlayer")) + + +func test_debug_mode_saves_to_a_file_of_its_own() -> void: + Settings.is_debug = true + var debug_path: String = Settings.saved_settings_path() + Settings.is_debug = false + + assert_str(debug_path).is_not_equal(Settings.saved_settings_path()) + + +func test_missing_config_file_is_written_with_everything_commented_out() -> void: + var config: IniFile = Settings.load_config() + + assert_bool(FileAccess.file_exists(Settings.config_path())).is_true() + assert_array(config.get_sections()).is_empty() + var text: String = FileAccess.get_file_as_string(Settings.config_path()) + for setting: String in ["#url = ", "#password =", "#debug = "]: + assert_str(text).contains(setting) + + +func test_commented_out_config_gives_the_defaults() -> void: + Settings.apply_config(Settings.load_config()) + + assert_bool(Settings.is_debug).is_equal(OS.has_feature("debug")) + var server: String = Settings.DEBUG_SERVER if Settings.is_debug else Settings.SERVERS[0] + assert_str(Settings.default_path).is_equal(server) + assert_str(Settings.api_password).is_empty() + + +func test_config_sets_server_password_and_debug() -> void: + _write_config("[server]\nurl = https://tmusic.sanplex.xyz/\n" + + "password = secret\n[game]\ndebug = false\n") + + Settings.apply_config(Settings.load_config()) + + assert_bool(Settings.is_debug).is_false() + assert_str(Settings.default_path).is_equal("https://tmusic.sanplex.xyz") + assert_int(Settings.selected_server).is_equal(1) + assert_array(Settings.servers).is_equal(Settings.SERVERS) + assert_str(Settings.api_password).is_equal("secret") + + +func test_server_not_in_the_list_is_added_to_it() -> void: + _write_config("[server]\nurl = http://music.home:9000\n") + + Settings.apply_config(Settings.load_config()) + + assert_str(Settings.default_path).is_equal("http://music.home:9000") + assert_str(Settings.servers[Settings.selected_server]).is_equal("http://music.home:9000") + assert_int(Settings.servers.size()).is_equal(Settings.SERVERS.size() + 1) + + +func test_debug_mode_from_the_config_uses_the_local_server() -> void: + _write_config("[game]\ndebug = true\n") + + Settings.apply_config(Settings.load_config()) + + assert_bool(Settings.is_debug).is_true() + assert_str(Settings.default_path).is_equal(Settings.DEBUG_SERVER) + assert_int(Settings.winning_score).is_equal(Settings.DEBUG_WINNING_SCORE) + + +func test_both_comment_styles_work() -> void: + _write_config("# hash comment\n[server]\n; semicolon comment\n#url = http://a\n" + + "password = pa;ss#word\n # indented\n[game]\ndebug = false\n") + + Settings.apply_config(Settings.load_config()) + + assert_str(Settings.api_password).is_equal("pa;ss#word") + assert_bool(Settings.is_debug).is_false() + assert_str(Settings.default_path).is_equal(Settings.SERVERS[0]) + + +func test_config_value_of_the_wrong_type_is_ignored() -> void: + _write_config("[game]\ndebug = maybe\n") + + Settings.apply_config(Settings.load_config()) + + assert_bool(Settings.is_debug).is_equal(OS.has_feature("debug")) + + +func test_config_file_is_never_overwritten() -> void: + _write_config("; mine\n[server]\npassword = secret\n") + + Settings.load_config() + Settings.save_settings() + + assert_str(FileAccess.get_file_as_string(Settings.config_path())) \ + .is_equal("; mine\n[server]\npassword = secret\n") diff --git a/tests/unit/test_quiz_settings_file.gd.uid b/tests/unit/test_settings_files.gd.uid similarity index 100% rename from tests/unit/test_quiz_settings_file.gd.uid rename to tests/unit/test_settings_files.gd.uid