Files

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