Files
cthulhu/internal/app/speech_settings_test.go

210 lines
7.7 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"
"path/filepath"
"testing"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"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()
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 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)
}
}
}