Files

259 lines
9.8 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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)
}
}
})
}
}