// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package config import ( "os" "path/filepath" "reflect" "strings" "testing" ) func TestDefaultsStrictValuesAndUnknownSettings(t *testing.T) { settings, err := Decode([]byte("[sound]\nenabled = false\n[speech]\nrate = 55\n")) if err != nil || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != 55 || !settings.Reading.CursorEcho { t.Fatalf("partial settings: %+v, %v", settings, err) } for _, data := range []string{"version = 4", "[sound]\ndriver = 'external'", "[sound]\nvolume = -0.1", "[sound]\nvolume = 1.1", "[sound]\nvolume = nan", "[sound]\nvolume = inf", "[speech]\nrate = 101", "[speech]\nmodule = 'bad\\nname'", "[keyboard]\ndevices = ['']", "[sound]\nenabled = 'false'", "[reading]\ncursorEcho = false", "[sound"} { if _, err := Decode([]byte(data)); err == nil { t.Errorf("accepted invalid settings: %q", data) } } } func TestTrackingModeDefaultsValidationAndSave(t *testing.T) { for _, version := range []string{"1", "2", "3"} { settings, err := Decode([]byte("version=" + version + "\n[reading]\ncursor_echo=false\n")) if err != nil || settings.Reading.Mode != "cursor" || settings.Reading.CursorEcho { t.Fatal("older settings lost cursor behavior", version, settings, err) } } for _, mode := range []string{"cursor", "table", "highlight"} { settings, err := Decode([]byte("version=3\n[reading]\nmode='" + mode + "'\n")) if err != nil || settings.Reading.Mode != mode { t.Fatal(settings, err) } path := filepath.Join(t.TempDir(), "settings.toml") if err := Save(path, settings); err != nil { t.Fatal(err) } loaded, _, err := Load(path, nil) if err != nil || loaded.Reading.Mode != mode { t.Fatal(loaded, err) } } for _, mode := range []string{"", "invalid", "Cursor"} { if _, err := Decode([]byte("version=3\n[reading]\nmode='" + mode + "'\n")); err == nil { t.Fatal("unsupported tracking mode accepted", mode) } } } func TestSettingsSelectionDoesNotHideInvalidOrExplicitMissingFiles(t *testing.T) { root := t.TempDir() userPath, systemPath := filepath.Join(root, "user.toml"), filepath.Join(root, "system.toml") if err := os.WriteFile(systemPath, []byte("[speech]\nrate = 30\n"), 0600); err != nil { t.Fatal(err) } settings, selected, err := Load("", []string{userPath, systemPath}) if err != nil || selected != systemPath || settings.Presentation.Speech.Rate != 30 { t.Fatalf("system selection: %+v %s %v", settings, selected, err) } if err := os.WriteFile(userPath, []byte("[sound]\nenabled = false\n"), 0600); err != nil { t.Fatal(err) } settings, selected, err = Load("", []string{userPath, systemPath}) if err != nil || selected != userPath || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != 0 { t.Fatalf("user selection merged system values: %+v %s %v", settings, selected, err) } if err := os.WriteFile(userPath, []byte("invalid = true"), 0600); err != nil { t.Fatal(err) } if _, selected, err := Load("", []string{userPath, systemPath}); err == nil || selected != userPath { t.Fatal("hid invalid selected settings", selected, err) } if _, _, err := Load(filepath.Join(root, "missing.toml"), []string{systemPath}); err == nil { t.Fatal("ignored explicit missing file") } if got := Paths(root, 0); !reflect.DeepEqual(got, []string{SystemPath}) { t.Fatal(got) } if got := Paths(root, 1000); len(got) != 2 || !strings.HasPrefix(got[0], root) || got[1] != SystemPath { t.Fatal(got) } } func TestAtomicSaveRoundTripAndRejectedSavePreservesOriginal(t *testing.T) { path := filepath.Join(t.TempDir(), "settings", "settings.toml") settings := Defaults() settings.Presentation.Sound.Enabled, settings.Presentation.Speech.Enabled, settings.Reading.CursorEcho = false, false, false settings.Presentation.Speech.Rate, settings.Presentation.Speech.Module = 45, "example" settings.Presentation.Speech.Pitch, settings.Presentation.Speech.Volume = -30, 75 settings.Presentation.Sound.Volume = 0.25 settings.Keyboard.Devices = []string{"/dev/input/event1"} settings.Keyboard.CharacterEcho, settings.Keyboard.WordEcho, settings.Keyboard.EchoOnCapsLock = false, true, true if err := Save(path, settings); err != nil { t.Fatal(err) } got, _, err := Load(path, nil) if err != nil || !reflect.DeepEqual(got, settings) { t.Fatalf("round trip: %+v, %v", got, err) } info, err := os.Stat(path) if err != nil || info.Mode().Perm() != 0600 { t.Fatal("settings are not private", info, err) } original, err := os.ReadFile(path) if err != nil { t.Fatal(err) } settings.Presentation.Speech.Rate = 101 if err := Save(path, settings); err == nil { t.Fatal("saved invalid settings") } current, err := os.ReadFile(path) if err != nil || string(current) != string(original) { t.Fatal("rejected save changed original", err) } entries, err := os.ReadDir(filepath.Dir(path)) if err != nil || len(entries) != 1 { t.Fatal("temporary save leaked", entries, err) } } func TestShippedExampleMatchesDefaults(t *testing.T) { settings, _, err := Load("../../config/settings/settings.toml", nil) if err != nil { t.Fatal(err) } settings.Keyboard.Devices = nil // TOML's explicit empty array is equivalent. if !reflect.DeepEqual(settings, Defaults()) { t.Fatalf("example differs: %+v", settings) } } func TestSpeechParameterDefaultsAndBounds(t *testing.T) { for _, data := range []string{"version=1\n", "version=2\n", "version=3\n"} { settings, err := Decode([]byte(data)) if err != nil || settings.Presentation.Speech.Rate != 0 || settings.Presentation.Speech.Pitch != 0 || settings.Presentation.Speech.Volume != 100 { t.Fatal(settings, err) } } for _, setting := range []string{"pitch", "volume"} { for _, invalid := range []string{"-101", "101"} { if _, err := Decode([]byte("version=3\n[presentation.speech]\n" + setting + "=" + invalid)); err == nil { t.Fatal(setting, invalid) } } } } func TestClipboardPersistenceDefaultsAndSavedHistoryLimit(t *testing.T) { settings := Defaults() if settings.Clipboard.Persistent || settings.Clipboard.NumberOfClipboards != 200 { t.Fatal(settings.Clipboard) } for _, version := range []string{"1", "2", "3"} { loaded, err := Decode([]byte("version=" + version + "\n[clipboard]\nnumber_of_clipboards=50\n")) if err != nil || loaded.Clipboard.Persistent || loaded.Clipboard.NumberOfClipboards != 50 { t.Fatal(loaded.Clipboard, err) } } for _, limit := range []string{"0", "10001"} { if _, err := Decode([]byte("version=3\n[clipboard]\nnumber_of_clipboards=" + limit)); err == nil { t.Fatal("invalid history size accepted", limit) } } settings.Clipboard.Persistent = true path := filepath.Join(t.TempDir(), "settings.toml") if err := Save(path, settings); err != nil { t.Fatal(err) } loaded, _, err := Load(path, nil) if err != nil || loaded.Clipboard != settings.Clipboard { t.Fatal(loaded.Clipboard, err) } } func TestInternalUIDefaultsAndPersistence(t *testing.T) { for _, data := range []string{"", "version=3\n[internal_ui]\nannounce_grid_coordinates=false\n"} { settings, err := Decode([]byte(data)) if err != nil || settings.InternalUI.AnnounceGridCoordinates != (data == "") || !settings.InternalUI.AnnounceItemPosition { t.Fatal("omitted options lost defaults", settings.InternalUI, err) } } for _, coordinates := range []bool{false, true} { for _, position := range []bool{false, true} { settings := Defaults() settings.InternalUI = InternalUI{AnnounceGridCoordinates: coordinates, AnnounceItemPosition: position} path := filepath.Join(t.TempDir(), "settings.toml") if err := Save(path, settings); err != nil { t.Fatal(err) } loaded, _, err := Load(path, nil) if err != nil || loaded.InternalUI != settings.InternalUI { t.Fatal(loaded.InternalUI, err) } } } } func TestAtomicSavePermissionsForSystemAndPersonalSettings(t *testing.T) { for _, system := range []bool{false, true} { name := "personal" fileMode, dirMode := os.FileMode(0600), os.FileMode(0700) if system { name, fileMode, dirMode = "system", 0644, 0755 } t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings", "settings.toml") save := func(data []byte) error { if system { // Exercise the system writer in a private directory, without // writing to the installed configuration or requiring root. return save_data_with_permissions(path, data, fileMode, dirMode) } return save_data(path, data) } for _, data := range []string{"initial", "replacement"} { if err := save([]byte(data)); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil || info.Mode().Perm() != fileMode { t.Fatal("file permissions", info, err) } info, err = os.Stat(filepath.Dir(path)) if err != nil || info.Mode().Perm() != dirMode { t.Fatal("directory permissions", info, err) } content, err := os.ReadFile(path) if err != nil || string(content) != data { t.Fatal("contents", string(content), err) } // Simulate the permissions left by an older save (or an // overly permissive personal file) before replacing it again. if err := os.Chmod(path, fileMode^0044); err != nil { t.Fatal(err) } } }) } }