Files
cthulhu/internal/app/preferences_test.go

362 lines
15 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/sound"
"git.stormux.org/storm/cthulhu/internal/speech"
)
type settingsUpdateFunc func(context.Context, speech.Settings) error
func TestPreferencesInitialRetainsLargeVoiceCatalog(t *testing.T) {
catalog := speech.Catalog{
"module": make([]speech.Voice, 6000),
"other": {{Name: "Other voice", Language: "en"}},
}
for index := range catalog["module"] {
catalog["module"][index].Name = strings.Repeat("v", 100)
}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
worker.discover = func(context.Context, string) (speech.Catalog, error) { return catalog, nil }
response, err := worker.initial_response(t.Context())
if err != nil || !reflect.DeepEqual(response.Catalog, catalog) {
t.Fatalf("initial response lost discovered voices: err=%v", err)
}
}
func TestPreferencesWindowReportsHelperExit(t *testing.T) {
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil }
// The test executable does not implement --preferences-ui. It exits before
// opening a dialog, as a broken helper would on the affected machine.
err := worker.window(t.Context())
if err == nil || !strings.Contains(err.Error(), "helper exit") ||
!strings.Contains(err.Error(), "preferences-ui") {
t.Fatalf("helper startup error = %v", err)
}
}
func (update settingsUpdateFunc) UpdateSettings(ctx context.Context, settings speech.Settings) error {
return update(ctx, settings)
}
type recordingSoundSettings struct {
configurations []sound.Configuration
}
type recordingBrailleSettings struct {
values []bool
contraction []bool
tables []string
err error
}
func (recorder *recordingBrailleSettings) SetContraction(enabled bool, table string) {
recorder.contraction = append(recorder.contraction, enabled)
recorder.tables = append(recorder.tables, table)
}
func (recorder *recordingBrailleSettings) Configure(_ context.Context, enabled bool) error {
if recorder.err != nil {
return recorder.err
}
recorder.values = append(recorder.values, enabled)
return nil
}
func TestPreferencesBrailleApplyAndFailure(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
braille := &recordingBrailleSettings{}
worker.braille = braille
values := preferences.FromSettings(settings)
values.EnableBraille = true
values.EnableContractedBraille = true
values.BrailleContractionTable = "en-us-g2.ctb"
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if !response.Accepted || len(braille.values) != 1 || !braille.values[0] ||
len(braille.contraction) != 1 || !braille.contraction[0] || braille.tables[0] != "en-us-g2.ctb" {
t.Fatalf("enable: response=%+v calls=%v", response, braille.values)
}
loaded, err := config.Load(settings.Path)
if err != nil || !loaded.EnableBraille || !loaded.EnableContractedBraille || loaded.BrailleContractionTable != "en-us-g2.ctb" {
t.Fatalf("saved braille setting: %+v, %v", loaded, err)
}
braille.err = errors.New("helper unavailable")
values.EnableBraille = false
response = worker.apply(context.Background(), preferences.Request{Values: values})
if response.Accepted || !worker.settings.EnableBraille {
t.Fatalf("failed disable changed setting: %+v", response)
}
}
func (recorder *recordingSoundSettings) Configure(configuration sound.Configuration) {
recorder.configurations = append(recorder.configurations, configuration)
}
func TestPreferencesApplySoundSettingsLiveAndPersisted(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
live := &liveSettings{}
live.Store(settings)
recorder := &recordingSoundSettings{}
geometry := &fakeAdapter{}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
worker.live = live
worker.sound = recorder
worker.geometry = geometry
values := preferences.FromSettings(settings)
values.EnableSound = false
values.SoundVolume = 0.8
values.SoundSink = "pulse"
values.SoundTheme = "none"
values.PlaySoundForRole = false
values.PlaySoundForState = false
values.PlaySoundForPositionInSet = false
values.SpatializeObjectSounds = true
values.ProgressBar.OutputMode = "off"
values.ProgressBar.ReportBackground = true
values.RoleSoundPresentation = "speech_only"
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if !response.Accepted || len(recorder.configurations) != 1 {
t.Fatalf("apply = %+v; sound updates = %+v", response, recorder.configurations)
}
wantConfiguration := sound.Configuration{Enabled: false, Sink: "pulse", Theme: "none", Volume: 0.8}
if recorder.configurations[0] != wantConfiguration {
t.Fatalf("sound configuration = %+v, want %+v", recorder.configurations[0], wantConfiguration)
}
if geometry.captureSoundGeometry {
t.Fatal("disabled sound retained synchronous event geometry capture")
}
if got := preferences.FromSettings(live.Load()); !reflect.DeepEqual(got, values) {
t.Fatalf("live values = %+v, want %+v", got, values)
}
loaded, err := config.Load(settings.Path)
if err != nil {
t.Fatal(err)
}
if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) {
t.Fatalf("saved values = %+v, want %+v", got, values)
}
}
func TestCaptureSoundGeometryRequiresSpatializedObjectSounds(t *testing.T) {
settings := config.DefaultSettings()
settings.EnableSound = true
settings.SoundTheme = "default"
settings.SoundVolume = 1
settings.SpatializeObjectSounds = true
settings.PlaySoundForRole = true
if !captureSoundGeometry(settings) {
t.Fatal("spatialized role sounds did not enable event geometry capture")
}
settings.PlaySoundForRole = false
settings.PlaySoundForState = false
if !captureSoundGeometry(settings) {
t.Fatal("spatialized position sounds did not enable event geometry capture")
}
settings.PlaySoundForPositionInSet = false
if captureSoundGeometry(settings) {
t.Fatal("geometry capture remained enabled without object sound categories")
}
}
func TestPreferencesApplySaveAndRejectedDraft(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
var updates []speech.Settings
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error {
updates = append(updates, value)
return nil
}))
values := preferences.FromSettings(settings)
rate := 75.0
values.Rate = &rate
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if !response.Accepted || len(updates) != 1 || *updates[0].Rate != 52 {
t.Fatalf("apply = %+v; updates = %+v", response, updates)
}
if _, err := os.Stat(settings.Path); err != nil || response.Close {
t.Fatalf("Apply did not save and stay open: %v", err)
}
badRate := 100.0
bad := values
bad.Rate = &badRate
response = worker.apply(context.Background(), preferences.Request{Values: bad, Save: true})
if response.Accepted || len(updates) != 1 || *worker.settings.DefaultVoice.Rate != rate {
t.Fatal("invalid draft changed session")
}
response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true, Close: true})
if !response.Accepted || !response.Close {
t.Fatalf("save = %+v", response)
}
loaded, err := config.Load(settings.Path)
if err != nil || loaded.DefaultVoice.Rate == nil || *loaded.DefaultVoice.Rate != rate {
t.Fatalf("saved settings = %+v; error %v", loaded, err)
}
if !reflect.DeepEqual(loaded.CthulhuModifierKeys, settings.CthulhuModifierKeys) || loaded.EnableSound != settings.EnableSound {
t.Fatal("saving changed unrelated settings")
}
}
func TestPreferencesLegacyFileRemainsUntouched(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.toml")
contents := []byte("[general]\nenableSpeech = true\nunknownSetting = 'preserve me'\n")
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
settings, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
calls := 0
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { calls++; return nil }))
request := preferences.Request{Values: preferences.FromSettings(settings), Save: true}
if response := worker.apply(context.Background(), request); response.Accepted || response.CanSave || calls != 0 {
t.Fatalf("legacy save = %+v, calls %d", response, calls)
}
request.Save = false
if response := worker.apply(context.Background(), request); !response.Accepted || calls != 0 {
t.Fatalf("session apply = %+v", response)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != string(contents) {
t.Fatal("legacy file changed")
}
}
func TestPreferencesBackendFailureDoesNotCommit(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return errors.New("backend rejected voice") }))
values := preferences.FromSettings(settings)
values.Punctuation = 3
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if response.Accepted || !reflect.DeepEqual(worker.settings, settings) {
t.Fatalf("failed apply committed: %+v", response)
}
if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) {
t.Fatal("failed apply wrote a file")
}
}
func TestPreferencesSpeechChoicesApplyAndPersist(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
var applied speech.Settings
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error {
applied = value
return nil
}))
values := preferences.FromSettings(settings)
values.SpeechOutputModule = "espeak-ng"
values.SpeechVoiceFamily = &config.VoiceFamily{Name: "Generic Voice", Lang: "en", Dialect: "US", Variant: "male"}
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if !response.Accepted || applied.OutputModule != "espeak-ng" ||
applied.Language != "en-US" || applied.SynthesisVoice != "Generic Voice" {
t.Fatalf("apply = %+v; applied = %+v", response, applied)
}
if worker.settings.SpeechOutputModule != "espeak-ng" ||
worker.settings.DefaultVoice.Family == nil || worker.settings.DefaultVoice.Family.Name != "Generic Voice" {
t.Fatalf("session settings = %+v", worker.settings)
}
loaded, err := config.Load(settings.Path)
if err != nil || loaded.SpeechOutputModule != "espeak-ng" ||
loaded.DefaultVoice.Family == nil || loaded.DefaultVoice.Family.Name != "Generic Voice" {
t.Fatalf("loaded settings = %+v; error %v", loaded, err)
}
}
func TestPreferencesVoicePreviewDoesNotCommitAndCancelRestores(t *testing.T) {
settings := config.DefaultSettings()
originalRate, originalPitch, originalVolume := 40.0, 4.0, 6.0
settings.DefaultVoice.Rate = &originalRate
settings.DefaultVoice.AveragePitch = &originalPitch
settings.DefaultVoice.Gain = &originalVolume
var updates []speech.Settings
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error {
updates = append(updates, value)
return nil
}))
values := preferences.FromSettings(settings)
previewRate, previewPitch, previewVolume := 75.0, 7.5, 8.0
values.Rate = &previewRate
values.Pitch = &previewPitch
values.Volume = &previewVolume
response := worker.preview(context.Background(), preferences.Request{Values: values, Preview: true})
if !response.Accepted || !response.Preview || len(updates) != 1 ||
updates[0].Rate == nil || *updates[0].Rate != 52 ||
updates[0].Pitch == nil || *updates[0].Pitch != 60 ||
updates[0].Volume == nil || *updates[0].Volume != 85 {
t.Fatalf("preview = %+v; updates = %+v", response, updates)
}
if worker.settings.DefaultVoice.Rate == nil || *worker.settings.DefaultVoice.Rate != originalRate ||
worker.settings.DefaultVoice.AveragePitch == nil || *worker.settings.DefaultVoice.AveragePitch != originalPitch ||
worker.settings.DefaultVoice.Gain == nil || *worker.settings.DefaultVoice.Gain != originalVolume {
t.Fatal("preview committed session settings")
}
response = worker.restorePreview(context.Background())
if !response.Accepted || !response.Close || len(updates) != 2 ||
updates[1].Rate == nil || *updates[1].Rate != -18 ||
updates[1].Pitch == nil || *updates[1].Pitch != -10 ||
updates[1].Volume == nil || *updates[1].Volume != 55 {
t.Fatalf("restore = %+v; updates = %+v", response, updates)
}
}
func TestPreferencesSaveFailureReportsSessionCommit(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error {
// Another writer changes the file after preflight, before Store.Save.
return os.WriteFile(settings.Path, []byte("schema-version = 999\n"), 0o600)
}))
values := preferences.FromSettings(settings)
values.Punctuation = 3
response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true})
if !response.Accepted || !strings.Contains(response.Message, "could not save") || worker.settings.VerbalizePunctuationStyle != 3 {
t.Fatalf("partial save result = %+v", response)
}
got, _ := os.ReadFile(settings.Path)
if string(got) != "schema-version = 999\n" {
t.Fatal("save replaced another writer's incompatible file")
}
}
func TestPreferencesOpenCoalescesAndStopJoins(t *testing.T) {
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
for range 100 {
worker.Open()
}
if len(worker.open) != 1 {
t.Fatal("open requests did not coalesce")
}
<-worker.open
ctx, cancel := context.WithCancel(context.Background())
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
cancel()
if err := worker.Stop(context.Background()); err != nil {
t.Fatal(err)
}
}