388 lines
12 KiB
Go
388 lines
12 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.
|
|
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestStoreSaveAndLoadRoundTrip(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "cthulhu", "settings.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := DefaultSettings()
|
|
settings.EnableSpeech = false
|
|
settings.SpeechHistoryLimit = 1000
|
|
settings.SayAllStyle = "sentence"
|
|
settings.Reading.EnableMnemonicSpeaking = false
|
|
settings.Reading.SayAllContextList = false
|
|
settings.Reading.SayAllContextPanel = false
|
|
settings.Reading.PresentTooltips = true
|
|
settings.Reading.EnableMouseReview = true
|
|
settings.EnableBraille = true
|
|
settings.EnableContractedBraille = true
|
|
settings.BrailleContractionTable = "en-us-g2.ctb"
|
|
settings.Braille = BrailleOptions{AbbreviateRoles: true, DisableEOL: true, WordWrap: true,
|
|
Verbosity: 0, SelectionIndicator: 0x40, LinkIndicator: 0x80,
|
|
FlashMessages: true, FlashTime: 3200, FlashPersistent: true, FlashDetailed: false}
|
|
settings.VerbalizePunctuationStyle = 3
|
|
settings.CthulhuModifierKeys = []string{"Insert"}
|
|
rate := 72.0
|
|
settings.DefaultVoice.Rate = &rate
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if permissions := info.Mode().Perm(); permissions != 0o600 {
|
|
t.Fatalf("settings permissions = %04o, want 0600", permissions)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(contents), "schema-version = 2") ||
|
|
!strings.Contains(string(contents), "[profiles.default.speech]") ||
|
|
!strings.Contains(string(contents), "history-limit = 1000") {
|
|
t.Fatalf("settings do not contain schema version:\n%s", contents)
|
|
}
|
|
|
|
reopened, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := reopened.Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
settings.Path, settings.Loaded, settings.ActiveProfile = path, true, "default"
|
|
if !reflect.DeepEqual(loaded, settings) {
|
|
t.Fatalf("loaded settings differ:\n got: %#v\nwant: %#v", loaded, settings)
|
|
}
|
|
}
|
|
|
|
func TestLoadUpgradesLegacyDateFormatIDs(t *testing.T) {
|
|
for source, target := range map[string]string{
|
|
"ymd": "numbers-ymd",
|
|
"mdy": "numbers-mdy",
|
|
"dmy": "numbers-dmy",
|
|
} {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
contents := "schema-version = 2\nactive-profile = 'default'\n[profiles.default.presentation]\ntime-format = '24-hour'\ndate-format = '" + source + "'\n"
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("%s: Load: %v", source, err)
|
|
}
|
|
if settings.TimeFormat != "24-hour" || settings.DateFormat != target {
|
|
t.Fatalf("%s: formats = (%s, %s), want (24-hour, %s)", source,
|
|
settings.TimeFormat, settings.DateFormat, target)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStoreMissingFileUsesDefaults(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := store.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if settings.Loaded || settings.Path != path || !settings.EnableSpeech ||
|
|
settings.SpeechHistoryLimit != DefaultSpeechHistoryLimit {
|
|
t.Fatalf("missing settings = %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestStoreRejectsSchemaVersionsAndUnknownFields(t *testing.T) {
|
|
for name, contents := range map[string]string{
|
|
"missing": `[general]`,
|
|
"future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n",
|
|
"unknown": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := store.Load()
|
|
if err == nil {
|
|
t.Fatal("Load accepted incompatible settings")
|
|
}
|
|
if !reflect.DeepEqual(loaded, store.Current()) || loaded.Loaded {
|
|
t.Fatalf("last good settings were not retained: %#v", loaded)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStoreVersionFixtures(t *testing.T) {
|
|
current, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := decodeWritable(current)
|
|
if err != nil {
|
|
t.Fatalf("decode current fixture: %v", err)
|
|
}
|
|
if settings.SpeakDescription || settings.VerbalizePunctuationStyle != 2 || settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 65 {
|
|
t.Fatalf("current fixture settings = %#v", settings)
|
|
}
|
|
|
|
future, err := os.ReadFile(filepath.Join("testdata", "settings-v3.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := decodeWritable(future); err == nil || !strings.Contains(err.Error(), "schema version 3") {
|
|
t.Fatalf("future fixture error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreLoadFailureRetainsLastGood(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := DefaultSettings()
|
|
settings.SpeakDescription = false
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
good := store.Current()
|
|
if err := os.WriteFile(path, []byte("schema-version = 2\n[profiles\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := store.Load()
|
|
if err == nil {
|
|
t.Fatal("Load accepted malformed settings")
|
|
}
|
|
if !reflect.DeepEqual(loaded, good) || !reflect.DeepEqual(store.Current(), good) {
|
|
t.Fatalf("last good changed:\n got: %#v\nwant: %#v", loaded, good)
|
|
}
|
|
}
|
|
|
|
func TestStoreValidationFailureDoesNotTouchFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := DefaultSettings()
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings.SoundVolume = 2
|
|
if err := store.Save(settings); err == nil {
|
|
t.Fatal("Save accepted invalid settings")
|
|
}
|
|
after, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(after, original) {
|
|
t.Fatal("validation failure changed the settings file")
|
|
}
|
|
}
|
|
|
|
func TestStoreRefusesToReplaceLegacyFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "user-settings.toml")
|
|
legacy := []byte("[general]\nenableSpeech = false\nunknownPythonSetting = true\n")
|
|
if err := os.WriteFile(path, legacy, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Save(DefaultSettings()); err == nil || !strings.Contains(err.Error(), "refuse to replace") {
|
|
t.Fatalf("Save error = %v", err)
|
|
}
|
|
after, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(after, legacy) {
|
|
t.Fatal("legacy file changed")
|
|
}
|
|
}
|
|
|
|
func TestDefaultStorePathUsesGoDevelopmentSettingsPath(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
|
|
path, err := DefaultStorePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "user-settings-go.toml")
|
|
if path != want {
|
|
t.Fatalf("DefaultStorePath = %q, want %q", path, want)
|
|
}
|
|
}
|
|
|
|
func TestStoreRenameFailurePreservesOriginalAndCleansTemporary(t *testing.T) {
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "settings.toml")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := DefaultSettings()
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.rename = func(_, _ string) error { return errors.New("injected rename failure") }
|
|
settings.EnableSpeech = false
|
|
if err := store.Save(settings); err == nil || !strings.Contains(err.Error(), "injected rename failure") {
|
|
t.Fatalf("Save error = %v", err)
|
|
}
|
|
after, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(after, original) {
|
|
t.Fatal("rename failure changed the settings file")
|
|
}
|
|
temporary, err := filepath.Glob(filepath.Join(directory, ".settings-*.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(temporary) != 0 {
|
|
t.Fatalf("temporary files remain: %v", temporary)
|
|
}
|
|
}
|
|
|
|
func TestStoreCopiesMutableSettings(t *testing.T) {
|
|
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := DefaultSettings()
|
|
rate := 40.0
|
|
settings.DefaultVoice.Rate = &rate
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings.CthulhuModifierKeys[0] = "changed"
|
|
rate = 99
|
|
current := store.Current()
|
|
if current.CthulhuModifierKeys[0] != "Insert" || current.DefaultVoice.Rate == nil || *current.DefaultVoice.Rate != 40 {
|
|
t.Fatalf("stored settings alias caller memory: %#v", current)
|
|
}
|
|
current.CthulhuModifierKeys[0] = "changed again"
|
|
*current.DefaultVoice.Rate = 1
|
|
again := store.Current()
|
|
if again.CthulhuModifierKeys[0] != "Insert" || *again.DefaultVoice.Rate != 40 {
|
|
t.Fatalf("Current aliases store memory: %#v", again)
|
|
}
|
|
}
|
|
|
|
func TestStoreSavePreservesOtherProfilesAndApplicationMappings(t *testing.T) {
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "user-settings-go.toml")
|
|
fixture, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, fixture, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings, err := store.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings.VerbalizePunctuationStyle = 3
|
|
if err := store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
document, loaded, err := decodeDocument(contents)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loaded.VerbalizePunctuationStyle != 3 || loaded.ActiveProfile != "default" {
|
|
t.Fatalf("saved active profile = %#v", loaded)
|
|
}
|
|
if document.Applications["org.example.Editor"] != "quiet" {
|
|
t.Fatalf("application mappings = %#v", document.Applications)
|
|
}
|
|
quiet := DefaultSettings()
|
|
applyProfile(&quiet, document.Profiles["quiet"])
|
|
if quiet.EnableSpeech {
|
|
t.Fatal("save changed the unrelated quiet profile")
|
|
}
|
|
}
|
|
|
|
func TestStoreRejectsInvalidProfileTopology(t *testing.T) {
|
|
for name, contents := range map[string]string{
|
|
"missing active profile": "schema-version = 2\nactive-profile = 'missing'\n[profiles.default]\n",
|
|
"missing application profile": "schema-version = 2\nactive-profile = 'default'\n[applications]\n'org.example.Editor' = 'missing'\n[profiles.default]\n",
|
|
"invalid inactive profile": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n[profiles.broken.sound]\nvolume = 2.0\n",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := decodeWritable([]byte(contents)); err == nil {
|
|
t.Fatal("invalid profile topology was accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureUnchangedDetectsExternalCreationAndModification(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "user-settings-go.toml")
|
|
if err := ensureUnchanged(path, nil); err != nil {
|
|
t.Fatalf("missing file changed unexpectedly: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("created"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureUnchanged(path, nil); err == nil || !strings.Contains(err.Error(), "changed while being saved") {
|
|
t.Fatalf("external creation error = %v", err)
|
|
}
|
|
if err := ensureUnchanged(path, []byte("original")); err == nil || !strings.Contains(err.Error(), "changed while being saved") {
|
|
t.Fatalf("external modification error = %v", err)
|
|
}
|
|
if err := ensureUnchanged(path, []byte("created")); err != nil {
|
|
t.Fatalf("unchanged file rejected: %v", err)
|
|
}
|
|
}
|