362 lines
15 KiB
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)
|
|
}
|
|
}
|