420 lines
17 KiB
Go
420 lines
17 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"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
"git.stormux.org/storm/cthulhu/internal/speech"
|
|
)
|
|
|
|
func TestSpeechShortcutsApplySaveAndFailure(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
rate := 50.0
|
|
settings.DefaultVoice.Rate = &rate
|
|
var applied speech.Settings
|
|
var updateError error
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, next speech.Settings) error {
|
|
if updateError != nil {
|
|
return updateError
|
|
}
|
|
applied = next
|
|
return nil
|
|
}))
|
|
worker.live = &liveSettings{}
|
|
worker.live.Store(settings)
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
command := func(c input.Command) { t.Helper(); worker.handleSpeechCommand(context.Background(), c) }
|
|
command(input.IncreaseSpeechSetting)
|
|
if message != "Rate 51" || applied.Rate == nil || *applied.Rate != 4 || *worker.live.Load().DefaultVoice.Rate != 51 || rate != 50 {
|
|
t.Fatalf("rate: message %q, applied %+v, original %g", message, applied, rate)
|
|
}
|
|
updateError = errors.New("backend unavailable")
|
|
command(input.IncreaseSpeechSetting)
|
|
if message != "Speech settings were not applied" || *worker.live.Load().DefaultVoice.Rate != 51 {
|
|
t.Fatal("failed update changed session")
|
|
}
|
|
updateError = nil
|
|
worker.catalog = speech.Catalog{"": {{Name: "default", Language: "en"}}, "alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}}, "beta": {{Name: "three", Language: "en"}}}
|
|
for _, want := range []string{"Pitch server default", "Volume server default", "Speech-dispatcher module server default"} {
|
|
command(input.SelectNextSpeechSetting)
|
|
if message != want {
|
|
t.Fatalf("got %q, want %q", message, want)
|
|
}
|
|
}
|
|
command(input.IncreaseSpeechSetting)
|
|
if applied.OutputModule != "alpha" || applied.SynthesisVoice != "one" {
|
|
t.Fatalf("module: %+v", applied)
|
|
}
|
|
command(input.SelectNextSpeechSetting)
|
|
command(input.IncreaseSpeechSetting)
|
|
if applied.SynthesisVoice != "two" {
|
|
t.Fatalf("voice: %+v", applied)
|
|
}
|
|
command(input.SelectNextSpeechSetting)
|
|
if worker.speechSetting != 4 {
|
|
t.Fatal("selector wrapped")
|
|
}
|
|
command(input.SaveSpeechSettings)
|
|
if message != "Speech settings saved" {
|
|
t.Fatal(message)
|
|
}
|
|
store, err := config.NewStore(settings.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, err := store.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.SpeechOutputModule != "alpha" || saved.DefaultVoice.Family.Name != "two" || *saved.DefaultVoice.Rate != 51 {
|
|
t.Fatalf("saved settings did not retain speech choices: %+v", saved.DefaultVoice)
|
|
}
|
|
worker.settings.Path = t.TempDir()
|
|
worker.globalSettings.Path = worker.settings.Path
|
|
command(input.SaveSpeechSettings)
|
|
if message != "Speech settings could not be saved" {
|
|
t.Fatal(message)
|
|
}
|
|
// A backend failure must not affect later selection or saving.
|
|
command(input.SelectPreviousSpeechSetting)
|
|
if worker.speechSetting != 3 {
|
|
t.Fatal("selector did not recover")
|
|
}
|
|
}
|
|
|
|
func TestQuickSpeechFollowsWindowSwitchesAndSavesGlobally(t *testing.T) {
|
|
for index, name := range []string{"rate", "pitch", "volume", "synthesizer", "voice"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
global := config.DefaultSettings()
|
|
global.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
rate, pitch, gain := 50.0, 5.0, 5.0
|
|
global.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain,
|
|
Family: &config.VoiceFamily{Name: "one", Lang: "en"}}
|
|
global.SpeechOutputModule = "alpha"
|
|
application := global
|
|
appRate, appPitch, appGain := 25.0, 2.0, 2.0
|
|
application.DefaultVoice = config.Voice{Rate: &appRate, AveragePitch: &appPitch, Gain: &appGain,
|
|
Family: &config.VoiceFamily{Name: "three", Lang: "en"}}
|
|
application.SpeechOutputModule = "beta"
|
|
application.TimeFormat = "24-hour"
|
|
store, err := config.NewStore(global.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Save(global); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, app := range []string{"Cached Application", "New Application"} {
|
|
if err := store.SaveForApplication(app, application); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
original, err := os.ReadFile(global.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var applied speech.Settings
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global,
|
|
settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil }))
|
|
worker.live = &liveSettings{}
|
|
worker.live.Store(global)
|
|
worker.catalog = speech.Catalog{"alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}},
|
|
"beta": {{Name: "three", Language: "en"}}}
|
|
ctx := t.Context()
|
|
worker.switchApplication(ctx, "Cached Application")
|
|
worker.switchApplication(ctx, "Presentation")
|
|
worker.speechSetting = index
|
|
worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting)
|
|
adjusted := worker.settings
|
|
check := func(base config.Settings) {
|
|
t.Helper()
|
|
switch index {
|
|
case 0:
|
|
value := 51.0
|
|
base.DefaultVoice.Rate = &value
|
|
case 1:
|
|
value := 5.1
|
|
base.DefaultVoice.AveragePitch = &value
|
|
case 2:
|
|
value := 5.1
|
|
base.DefaultVoice.Gain = &value
|
|
case 3:
|
|
base.SpeechOutputModule = "beta"
|
|
base.DefaultVoice.Family = &config.VoiceFamily{Name: "three", Lang: "en"}
|
|
case 4:
|
|
base.SpeechOutputModule = "alpha"
|
|
base.DefaultVoice.Family = &config.VoiceFamily{Name: "two", Lang: "en"}
|
|
}
|
|
if !reflect.DeepEqual(speechSettingsFromConfig(worker.settings), speechSettingsFromConfig(base)) ||
|
|
!reflect.DeepEqual(speechSettingsFromConfig(worker.live.Load()), speechSettingsFromConfig(base)) ||
|
|
!reflect.DeepEqual(applied, speechSettingsFromConfig(base)) {
|
|
t.Fatalf("speech choice lost in %q: got %+v, want %+v", worker.activeApp, worker.settings.DefaultVoice, base.DefaultVoice)
|
|
}
|
|
if worker.settings.TimeFormat != base.TimeFormat {
|
|
t.Fatal("quick speech changed unrelated application preferences")
|
|
}
|
|
}
|
|
check(global)
|
|
for _, app := range []string{"Cached Application", "New Application", "Other Application", "Presentation"} {
|
|
worker.switchApplication(ctx, app)
|
|
base := global
|
|
if app == "Cached Application" || app == "New Application" {
|
|
base = application
|
|
}
|
|
check(base)
|
|
}
|
|
contents, err := os.ReadFile(global.Path)
|
|
if err != nil || string(contents) != string(original) {
|
|
t.Fatalf("unsaved shortcuts wrote settings: %v", err)
|
|
}
|
|
// Saving while an application profile is active must update the global
|
|
// speech choice without copying its other preferences into the default.
|
|
worker.switchApplication(ctx, "Cached Application")
|
|
worker.handleSpeechCommand(ctx, input.SaveSpeechSettings)
|
|
loaded, err := store.Load()
|
|
if err != nil || !reflect.DeepEqual(speechSettingsFromConfig(loaded), speechSettingsFromConfig(adjusted)) || loaded.TimeFormat != global.TimeFormat {
|
|
t.Fatalf("global speech settings were not saved: %+v, %v", loaded, err)
|
|
}
|
|
appLoaded, mapped, err := store.LoadForApplication("Cached Application")
|
|
if err != nil || !mapped || !reflect.DeepEqual(speechSettingsFromConfig(appLoaded), speechSettingsFromConfig(application)) {
|
|
t.Fatalf("save shortcut overwrote application profile: %+v, %v", appLoaded, err)
|
|
}
|
|
if _, mapped, err := store.LoadForApplication("Presentation"); err != nil || mapped {
|
|
t.Fatalf("quick speech created an application profile: %t, %v", mapped, err)
|
|
}
|
|
worker.switchApplication(ctx, "Presentation")
|
|
check(global)
|
|
worker.switchApplication(ctx, "Cached Application")
|
|
check(application)
|
|
restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded,
|
|
settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil }))
|
|
restarted.switchApplication(ctx, "Presentation")
|
|
if !reflect.DeepEqual(speechSettingsFromConfig(restarted.settings), speechSettingsFromConfig(adjusted)) {
|
|
t.Fatal("saved global speech did not survive restart")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQuickSpeechFailureAndPreferencesReplacement(t *testing.T) {
|
|
global := config.DefaultSettings()
|
|
global.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
rate := 50.0
|
|
global.DefaultVoice.Rate = &rate
|
|
var updateError error
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global,
|
|
settingsUpdateFunc(func(context.Context, speech.Settings) error { return updateError }))
|
|
application := global
|
|
appRate := 25.0
|
|
application.DefaultVoice.Rate = &appRate
|
|
worker.appSettings["Example Editor"] = application
|
|
ctx := t.Context()
|
|
worker.switchApplication(ctx, "Presentation")
|
|
worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting)
|
|
worker.handleSpeechCommand(ctx, input.SelectNextSpeechSetting)
|
|
worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting)
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
updateError = errors.New("backend unavailable")
|
|
worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting)
|
|
updateError = nil
|
|
path := worker.globalSettings.Path
|
|
worker.globalSettings.Path = t.TempDir()
|
|
worker.handleSpeechCommand(ctx, input.SaveSpeechSettings)
|
|
if message != "Speech settings could not be saved" {
|
|
t.Fatal(message)
|
|
}
|
|
worker.globalSettings.Path = path
|
|
worker.switchApplication(ctx, "Example Editor")
|
|
if *worker.settings.DefaultVoice.Rate != 51 || *worker.settings.DefaultVoice.AveragePitch != 5.1 {
|
|
t.Fatal("failed adjustment or save lost prior session choices")
|
|
}
|
|
if !worker.beginEdit(ctx, "") {
|
|
t.Fatal("could not open global preferences")
|
|
}
|
|
values := preferences.FromSettings(worker.editing)
|
|
appliedRate := 60.0
|
|
values.Rate = &appliedRate
|
|
updateError = errors.New("backend unavailable")
|
|
if response := worker.apply(ctx, preferences.Request{Values: values}); response.Accepted {
|
|
t.Fatal("failed preferences apply was accepted")
|
|
}
|
|
if settings, err := worker.applicationSettings("Example Editor"); err != nil || *settings.DefaultVoice.Rate != 51 {
|
|
t.Fatalf("failed preferences apply discarded quick speech: %v", err)
|
|
}
|
|
updateError = nil
|
|
if response := worker.apply(ctx, preferences.Request{Values: values}); !response.Accepted {
|
|
t.Fatal(response.Message)
|
|
}
|
|
if *worker.settings.DefaultVoice.Rate != 25 || *worker.globalSettings.DefaultVoice.Rate != 60 {
|
|
t.Fatal("explicit preferences apply did not restore application profile ownership")
|
|
}
|
|
worker.editingActive = false
|
|
worker.switchApplication(ctx, "Presentation")
|
|
if *worker.settings.DefaultVoice.Rate != 60 {
|
|
t.Fatal("old quick speech choice replaced applied preferences")
|
|
}
|
|
}
|
|
|
|
func TestSpeechShortcutReadsAndSavesRoundedNumbers(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
rate, pitch, gain := 89.99999999999999, 4.499999999999998, 4.999999999999999
|
|
settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain}
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
for index, want := range []string{"Rate 90", "Pitch 4.5", "Volume 5"} {
|
|
command := input.SelectNextSpeechSetting
|
|
if index == 0 {
|
|
command = input.SelectPreviousSpeechSetting
|
|
}
|
|
worker.handleSpeechCommand(t.Context(), command)
|
|
if message != want {
|
|
t.Fatalf("got %q, want %q", message, want)
|
|
}
|
|
}
|
|
if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("checking speech settings created a file: %v", err)
|
|
}
|
|
worker.handleSpeechCommand(t.Context(), input.SaveSpeechSettings)
|
|
store, err := config.NewStore(settings.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, err := store.Load()
|
|
if err != nil || *saved.DefaultVoice.Rate != 90 || *saved.DefaultVoice.AveragePitch != 4.5 || *saved.DefaultVoice.Gain != 5 {
|
|
t.Fatalf("speech numbers were not saved rounded: %+v, %v", saved.DefaultVoice, err)
|
|
}
|
|
}
|
|
|
|
func TestSpeechShortcutNumericBounds(t *testing.T) {
|
|
for index, maximum := range []float64{99, 9, 9} {
|
|
settings := config.DefaultSettings()
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil }))
|
|
worker.speechSetting = index
|
|
fields := []**float64{&worker.settings.DefaultVoice.Rate, &worker.settings.DefaultVoice.AveragePitch, &worker.settings.DefaultVoice.Gain}
|
|
*fields[index] = &maximum
|
|
worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting)
|
|
if **fields[index] != maximum {
|
|
t.Fatal("upper bound exceeded")
|
|
}
|
|
zero := 0.0
|
|
*fields[index] = &zero
|
|
worker.handleSpeechCommand(context.Background(), input.DecreaseSpeechSetting)
|
|
if **fields[index] != 0 {
|
|
t.Fatal("lower bound exceeded")
|
|
}
|
|
worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting)
|
|
want := 0.1
|
|
if index == 0 {
|
|
want = 1
|
|
}
|
|
if **fields[index] != want {
|
|
t.Fatalf("step %g, want %g", **fields[index], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSpeechShortcutUnavailableDiscovery(t *testing.T) {
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
|
|
worker.speechSetting = 3
|
|
worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, errors.New("offline") }
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting)
|
|
if message != "Speech settings unavailable" || worker.settings.SpeechOutputModule != "" {
|
|
t.Fatal(message)
|
|
}
|
|
}
|
|
|
|
func TestQuickSpeechESpeakLocale(t *testing.T) {
|
|
voices := []speech.Voice{
|
|
{Name: "de+max", Language: "de"},
|
|
{Name: "en-us+max", Language: "en-us"},
|
|
{Name: "en-gb", Language: "en-gb"},
|
|
{Name: "en-us", Language: "en-us", Variant: "male"},
|
|
{Name: "en", Language: "en"},
|
|
{Name: "en-us+Storm", Language: "en-us"},
|
|
}
|
|
for _, tc := range []struct {
|
|
name, all, messages, lang, module string
|
|
want []string
|
|
}{
|
|
{"exact", "en_US.UTF-8", "de_DE", "en_GB", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}},
|
|
{"messages", "", "en_US@custom", "de_DE", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}},
|
|
{"lang", "", "", "en-GB.UTF-8", "espeak-ng", []string{"en-gb"}},
|
|
{"language fallback", "en_AU", "", "", "espeak-ng", []string{"en"}},
|
|
{"C fallback", "C.UTF-8", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}},
|
|
{"missing locale", "", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}},
|
|
{"unavailable language", "zz_ZZ", "", "", "espeak-ng", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}},
|
|
{"other module", "en_US", "", "", "other", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("LC_ALL", tc.all)
|
|
t.Setenv("LC_MESSAGES", tc.messages)
|
|
t.Setenv("LANG", tc.lang)
|
|
got := quickSpeechVoices(tc.module, voices)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("voices = %v, want %v", got, tc.want)
|
|
}
|
|
for i, want := range tc.want {
|
|
if got[i].Name != want {
|
|
t.Fatalf("voices = %v, want %v", got, tc.want)
|
|
}
|
|
}
|
|
if voices[0].Name != "de+max" || voices[3].Name != "en-us" {
|
|
t.Fatal("catalog mutated")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSpeechShortcutESpeakDefaultAndVariants(t *testing.T) {
|
|
t.Setenv("LC_ALL", "en_US.UTF-8")
|
|
var applied speech.Settings
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), settingsUpdateFunc(func(_ context.Context, next speech.Settings) error {
|
|
applied = next
|
|
return nil
|
|
}))
|
|
worker.catalog = speech.Catalog{"espeak-ng": {
|
|
{Name: "de", Language: "de"},
|
|
{Name: "en-us+max", Language: "en-us"},
|
|
{Name: "en-us", Language: "en-us"},
|
|
{Name: "fr", Language: "fr"},
|
|
}}
|
|
worker.speechSetting = 3
|
|
worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting)
|
|
if applied.OutputModule != "espeak-ng" || applied.SynthesisVoice != "en-us" || applied.Language != "en-us" {
|
|
t.Fatalf("module switch: %+v", applied)
|
|
}
|
|
worker.handleSpeechCommand(context.Background(), input.SelectNextSpeechSetting)
|
|
for _, tc := range []struct {
|
|
command input.Command
|
|
want string
|
|
}{
|
|
{input.IncreaseSpeechSetting, "en-us+max"},
|
|
{input.IncreaseSpeechSetting, "en-us+max"},
|
|
{input.DecreaseSpeechSetting, "en-us"},
|
|
{input.DecreaseSpeechSetting, "en-us"},
|
|
} {
|
|
worker.handleSpeechCommand(context.Background(), tc.command)
|
|
if applied.SynthesisVoice != tc.want {
|
|
t.Fatalf("voice = %q, want %q", applied.SynthesisVoice, tc.want)
|
|
}
|
|
}
|
|
}
|