Files
cthulhu/internal/app/echo_preferences_test.go

161 lines
5.3 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"path/filepath"
"reflect"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
func TestEveryEchoPreferenceRoundTrips(t *testing.T) {
settings := config.DefaultSettings()
if !settings.EnableKeyEcho {
t.Fatal("key echo must default on")
}
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
values := preferences.FromSettings(settings)
// Flip each echo switch, including false defaults, to exercise both directions.
value := reflect.ValueOf(&values).Elem()
for i := 0; i < value.NumField(); i++ {
if value.Field(i).Kind() == reflect.Bool {
value.Field(i).SetBool(!value.Field(i).Bool())
}
}
rate, pitch, volume := 100.0, 10.0, 10.0
values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume, Family: &config.VoiceFamily{Name: "Test Voice", Lang: "en", Variant: "test"}}
values.EchoSpeechServer = "test-module"
next, err := values.Merge(settings)
if err != nil {
t.Fatal(err)
}
store, err := config.NewStore(settings.Path)
if err != nil {
t.Fatal(err)
}
if _, err = store.Load(); err != nil {
t.Fatal(err)
}
if err = store.Save(next); err != nil {
t.Fatal(err)
}
loaded, err := config.Load(settings.Path)
if err != nil {
t.Fatal(err)
}
if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) {
t.Fatalf("round trip = %+v; want %+v", got, values)
}
}
func TestApplyUpdatesRunningTextAndKeyboardEcho(t *testing.T) {
settings := config.DefaultSettings()
settings.EnableModifierKeys = false
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
live := &liveSettings{}
live.Store(settings)
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
worker.live = live
queue := &fakeSpeechQueue{spoken: make(chan string, 10), interrupt: make(chan struct{}, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 10)}
listener := &fakeInputListener{events: make(chan input.Event, 10), modifierKeycodes: input.NewModifierKeycodes(37)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}, 2)
go func() {
processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live)
done <- struct{}{}
}()
go func() {
processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, inputEchoSettingsFromConfig(settings), live, nil)
done <- struct{}{}
}()
defer func() { cancel(); <-done; <-done }()
expect := func(want string) {
t.Helper()
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
snapshot := atspi.NewSnapshot("/editor", "", "text", "", "")
snapshot.Editable = true
insert := func(text string) {
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, text)
}
insert("a")
expect("a")
listener.events <- input.NewKeyEvent(67, true, "F1")
expect("f1")
<-queue.interrupt
values := preferences.FromSettings(settings)
values.EnableKeyEcho = false
// Echo-only changes must work without a speech settings updater.
if r := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !r.Accepted || r.Close {
t.Fatalf("Apply = %+v", r)
}
insert("b")
insert("barrier")
expect("barrier")
listener.events <- input.NewKeyEvent(68, true, "F2")
listener.events <- input.NewKeyEvent(37, true, "Control_L")
for range 2 {
select {
case <-queue.interrupt:
case <-time.After(time.Second):
t.Fatal("keyboard barrier missing")
}
}
values.EnableKeyEcho = true
if r := worker.apply(ctx, preferences.Request{Values: values, Save: true, Close: true}); !r.Accepted || !r.Close {
t.Fatalf("OK = %+v", r)
}
insert("c")
expect("c")
listener.events <- input.NewKeyEvent(69, true, "F3")
expect("f3")
loaded, err := config.Load(settings.Path)
if err != nil || !loaded.EnableKeyEcho {
t.Fatalf("saved state: %+v, %v", loaded, err)
}
}
func TestCustomEchoVoiceInheritanceAndRouting(t *testing.T) {
settings := config.DefaultSettings()
settings.UseCustomEchoVoice = true
rate, pitch, volume := 65.0, 10.0, 10.0
settings.DefaultVoice = config.Voice{Rate: &rate, Family: &config.VoiceFamily{Name: "base", Lang: "en"}}
settings.EchoVoice = config.Voice{AveragePitch: &pitch, Gain: &volume}
settings.UseCustomEchoSpeechServer = true
settings.EchoSpeechServer = "echo-module"
got := echoSpeechSettings(settings)
if *got.Rate != 32 || *got.Pitch != 90 || *got.Volume != 100 || got.OutputModule != "echo-module" || got.SynthesisVoice != "base" {
t.Fatalf("echo settings = %+v", got)
}
if !customEcho(settings, "key") || !customEcho(settings, "character") || customEcho(settings, "word") || customEcho(settings, "sentence") || customEcho(settings, "") {
t.Fatal("wrong default custom voice routing")
}
settings.UseCustomEchoForWord = true
settings.UseCustomEchoForSentence = true
if !customEcho(settings, "word") || !customEcho(settings, "sentence") {
t.Fatal("custom routing flags ignored")
}
settings.EnableKeyEcho = false
if customEcho(settings, "key") {
t.Fatal("custom echo survived master disable")
}
}