Files

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