285 lines
9.2 KiB
Go
285 lines
9.2 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// 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 TestLoadVersionedSettingsMatchesStore(t *testing.T) {
|
|
path := filepath.Join("testdata", "settings-v2.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want, err := store.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("runtime settings = %#v, want store settings %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLegacySayAllStyleMapsToRuntimeChoice(t *testing.T) {
|
|
for legacy, want := range map[string]string{"0": "line", "1": "sentence"} {
|
|
settings, err := Load(writeConfig(t, "[general]\nsayAllStyle = "+legacy+"\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if settings.SayAllStyle != want {
|
|
t.Fatalf("legacy Say All style %s loaded as %q", legacy, settings.SayAllStyle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsInvalidVersionedSettingsWithoutWriting(t *testing.T) {
|
|
for name, contents := range map[string]string{
|
|
"old": "schema-version = 1\n[general]\n",
|
|
"future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n",
|
|
"zero": "schema-version = 0\n[general]\n",
|
|
"negative": "schema-version = -1\n[general]\n",
|
|
"wrong type": "schema-version = '1'\n[general]\n",
|
|
"unknown field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n",
|
|
"legacy field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\nenableSpeech = false\n",
|
|
"invalid value": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nvolume = 2.0\n",
|
|
"malformed": "schema-version = 2\n[profiles\n",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := writeConfig(t, contents)
|
|
if _, err := Load(path); err == nil {
|
|
t.Fatal("Load accepted invalid versioned settings")
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != contents {
|
|
t.Fatal("Load changed the settings file")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadMergesGeneralAndStartingProfile(t *testing.T) {
|
|
path := writeConfig(t, `
|
|
[general]
|
|
enableSpeech = true
|
|
speechServerFactory = "speechdispatcherfactory"
|
|
speakDescription = true
|
|
speechVerbosityLevel = 1
|
|
verbalizePunctuationStyle = 2
|
|
capitalizationStyle = "spell"
|
|
keyboardLayout = 1
|
|
cthulhuModifierKeys = ["Insert", "KP_Insert"]
|
|
enableKeyEcho = true
|
|
enableEchoByCharacter = false
|
|
enableEchoByWord = true
|
|
enableEchoBySentence = false
|
|
enableAlphabeticKeys = true
|
|
enableNumericKeys = true
|
|
enablePunctuationKeys = false
|
|
enableSpace = true
|
|
enableModifierKeys = true
|
|
enableFunctionKeys = true
|
|
enableActionKeys = true
|
|
enableNavigationKeys = false
|
|
enableDiacriticalKeys = false
|
|
startingProfile = ["Quiet", "quiet"]
|
|
unknownFutureSetting = true
|
|
|
|
[general.voices.default]
|
|
rate = 40.0
|
|
average-pitch = 5.0
|
|
gain = 8.0
|
|
|
|
[profiles.quiet]
|
|
speakDescription = false
|
|
enableKeyEcho = false
|
|
enableEchoByCharacter = true
|
|
enableEchoByWord = false
|
|
enableEchoBySentence = true
|
|
enableAlphabeticKeys = false
|
|
speechServerFactory = "cthulhu.speechdispatcherfactory"
|
|
|
|
[profiles.quiet.voices.default]
|
|
rate = 75.0
|
|
|
|
[profiles.quiet.voices.default.family]
|
|
name = "Generic Voice"
|
|
lang = "en"
|
|
dialect = "US"
|
|
`)
|
|
|
|
settings, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !settings.Loaded || settings.ActiveProfile != "quiet" {
|
|
t.Fatalf("loaded/profile = %v/%q, want true/quiet", settings.Loaded, settings.ActiveProfile)
|
|
}
|
|
if settings.SpeakDescription || settings.EnableKeyEcho {
|
|
t.Fatal("profile boolean overrides were not applied")
|
|
}
|
|
if !settings.EnableEchoByCharacter || settings.EnableEchoByWord || !settings.EnableEchoBySentence ||
|
|
settings.EnableAlphabeticKeys || settings.EnablePunctuationKeys ||
|
|
!settings.EnableNumericKeys || !settings.EnableSpace || !settings.EnableModifierKeys ||
|
|
!settings.EnableFunctionKeys || !settings.EnableActionKeys || settings.EnableNavigationKeys || settings.EnableDiacriticalKeys {
|
|
t.Fatalf("typing echo settings = %+v", settings)
|
|
}
|
|
if settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" {
|
|
t.Fatalf("speech server factory = %q", settings.SpeechServerFactory)
|
|
}
|
|
if settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 75 {
|
|
t.Fatalf("voice rate = %v, want 75", settings.DefaultVoice.Rate)
|
|
}
|
|
if settings.DefaultVoice.Gain == nil || *settings.DefaultVoice.Gain != 8 {
|
|
t.Fatalf("voice gain = %v, want inherited value 8", settings.DefaultVoice.Gain)
|
|
}
|
|
if settings.DefaultVoice.Family == nil || settings.DefaultVoice.Family.Name != "Generic Voice" {
|
|
t.Fatalf("voice family = %#v", settings.DefaultVoice.Family)
|
|
}
|
|
}
|
|
|
|
func TestLoadMissingFileUsesDefaults(t *testing.T) {
|
|
settings, err := Load(filepath.Join(t.TempDir(), "missing.toml"))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if settings.Loaded {
|
|
t.Fatal("missing file reported as loaded")
|
|
}
|
|
if !settings.EnableSpeech || !settings.SpeakDescription || !settings.Reading.EnableMnemonicSpeaking || settings.KeyboardLayout != 1 {
|
|
t.Fatalf("unexpected defaults: %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestDefaultPathUsesGoDevelopmentFile(t *testing.T) {
|
|
dataHome := filepath.Join(t.TempDir(), "data")
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
path, err := DefaultPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := filepath.Join(dataHome, "cthulhu", "user-settings-go.toml"); path != want {
|
|
t.Fatalf("DefaultPath = %q, want %q", path, want)
|
|
}
|
|
}
|
|
|
|
func TestDefaultLoadDoesNotFallBackToPythonSettings(t *testing.T) {
|
|
dataHome := filepath.Join(t.TempDir(), "data")
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
directory := filepath.Join(dataHome, "cthulhu")
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, "user-settings.toml"), []byte("[general]\nenableSpeech = false\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := Load("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if settings.Loaded || !settings.EnableSpeech || filepath.Base(settings.Path) != "user-settings-go.toml" {
|
|
t.Fatalf("default settings = %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsInvalidSettings(t *testing.T) {
|
|
path := writeConfig(t, "[general]\nkeyboardLayout = 9\n")
|
|
_, err := Load(path)
|
|
if err == nil || !strings.Contains(err.Error(), "keyboardLayout") {
|
|
t.Fatalf("Load error = %v, want keyboardLayout validation error", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsOversizedFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "user-settings.toml")
|
|
if err := os.WriteFile(path, []byte(strings.Repeat("#", maxConfigSize+1)), 0o600); err != nil {
|
|
t.Fatalf("write oversized config: %v", err)
|
|
}
|
|
_, err := Load(path)
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
|
t.Fatalf("Load error = %v, want size error", err)
|
|
}
|
|
}
|
|
|
|
func writeConfig(t *testing.T, contents string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "user-settings.toml")
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestSoundSettingsMergeAndValidate(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := os.WriteFile(path, []byte(`[general]
|
|
soundVolume = 0.4
|
|
soundTheme = "default"
|
|
[profiles.default]
|
|
enableSound = true
|
|
soundVolume = 0.7
|
|
soundSink = "pulse"
|
|
playSoundForRole = true
|
|
playSoundForState = true
|
|
roleSoundPresentation = "sound_only"
|
|
`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if settings.SoundVolume != 0.7 || !settings.PlaySoundForRole || !settings.PlaySoundForState || settings.RoleSoundPresentation != "sound_only" {
|
|
t.Fatalf("sound settings = %+v", settings)
|
|
}
|
|
if err := os.WriteFile(path, []byte("[general]\nroleSoundPresentation = \"speech_only\"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err != nil {
|
|
t.Fatalf("speech-only role presentation was rejected: %v", err)
|
|
}
|
|
for _, line := range []string{`soundVolume = -0.1`, `soundVolume = 1.1`, `soundVolume = nan`, `soundTheme = "../other"`, `soundSink = "invalid"`, `roleSoundPresentation = "invalid"`} {
|
|
if err := os.WriteFile(path, []byte("[general]\n"+line+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err == nil {
|
|
t.Fatalf("accepted %s", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsNonFiniteVoiceValues(t *testing.T) {
|
|
for _, value := range []string{"nan", "+inf", "-inf"} {
|
|
path := writeConfig(t, "[general.voices.default]\nrate = "+value+"\n")
|
|
if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "default voice rate") {
|
|
t.Fatalf("Load rate %s error = %v", value, err)
|
|
}
|
|
}
|
|
}
|