Files
fenrir/internal/app/speech_selection_test.go

318 lines
13 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/preferences"
"git.stormux.org/storm/fenrir/internal/speech"
)
type preparedVoice struct{ closed atomic.Bool }
func (p *preparedVoice) Say(string) error { return nil }
func (p *preparedVoice) Configure(speech.Parameters) error { return nil }
func (p *preparedVoice) Set_pitch(int) error { return nil }
func (p *preparedVoice) Cancel() error { return nil }
func (p *preparedVoice) Close() { p.closed.Store(true) }
type selectingVoice struct {
fakeVoice
replaced chan speech.Transport
replacementError error
}
func (s *selectingVoice) Replace(candidate speech.Transport) error {
if s.replacementError != nil {
candidate.Close()
return s.replacementError
}
s.replaced <- candidate
return nil
}
func selection_application(t *testing.T, terminal bool) (*application, *selectingVoice, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "settings.toml")
base := config.Defaults()
if err := config.Save(path, base); err != nil {
t.Fatal(err)
}
voice := &selectingVoice{replaced: make(chan speech.Transport, 8)}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
a, err := new_application(ctx, &fakeSource{}, &fakeKeys{}, voice, RunOptions{Settings: base, ConfigPath: path, TerminalMode: terminal})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for {
select {
case candidate := <-voice.replaced:
candidate.Close()
default:
return
}
}
})
return a, voice, path
}
func finish_selection(t *testing.T, a *application) {
t.Helper()
select {
case result := <-a.speechResults:
a.speech_result(result)
case <-time.After(time.Second):
t.Fatal("selection stalled")
}
}
func request_selection(t *testing.T, a *application, text string) string {
t.Helper()
reply := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING " + text, Reply: reply})
if a.speechPending {
finish_selection(t, a)
}
select {
case result := <-reply:
return result
case <-time.After(time.Second):
t.Fatal("remote response stalled")
return ""
}
}
func TestRemoteVoiceChangesAreAtomicAndResetRestoresTransport(t *testing.T) {
for _, terminal := range []bool{false, true} {
t.Run(map[bool]string{false: "tty", true: "terminal"}[terminal], func(t *testing.T) {
a, voice, path := selection_application(t, terminal)
base := a.effective_settings()
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return nil, errors.New("fixture unavailable") }
result := request_selection(t, a, "SET speech#module=fixture;speech#voice=Missing;speech#rate=0.25;speech#enabled=False")
if !strings.Contains(result, "fixture unavailable") || !reflect.DeepEqual(a.effective_settings(), base) || !reflect.DeepEqual(a.settings.saveDraft, base) {
t.Fatal("failed selection partially applied", result, a.effective_settings())
}
var prepared []config.Speech
a.prepareSpeech = func(selected config.Speech) (speech.Transport, error) {
prepared = append(prepared, selected)
return &preparedVoice{}, nil
}
result = request_selection(t, a, "SET speech#voice=Base Voice;speech#language=en-US;speech#module=fixture;speech#rate=0.25")
if result != "" || a.settings.live.Presentation.Speech.Voice != "Base Voice" || prepared[0].Voice != "Base Voice" || prepared[0].Language != "en-US" || a.settings.live.Presentation.Speech.Rate != -50 {
t.Fatal(result, prepared, a.settings.live)
}
saved, _, err := config.Load(path, nil)
if err != nil || saved.Presentation.Speech.Module != "" {
t.Fatal("SET wrote the file", saved, err)
}
if result = request_selection(t, a, "RESET"); result != "" || len(prepared) != 2 || prepared[1].Module != "" || !same_speech_selection(a.settings.live.Presentation.Speech, base.Presentation.Speech) {
t.Fatal("RESET did not replace transport", result, prepared, a.settings.live)
}
if a.speechPending {
t.Fatal("selection admission not released")
}
if len(voice.parameters) == 0 || voice.parameters[len(voice.parameters)-1] != speech_parameters(base) {
t.Fatal("RESET numeric parameters not restored", voice.parameters)
}
})
}
}
func TestHardwareOptionsReachStartupAndRemoteChangesStayAtomic(t *testing.T) {
for _, terminal := range []bool{false, true} {
a, _, _ := selection_application(t, terminal)
selected := a.settings.live.Presentation.Speech
selected.Driver, selected.HardwareDevice, selected.HardwareBaudRate = "dectalk", "/dev/fixture-synth", 19200
options := speech_options(selected)
if options.Hardware.Device != selected.HardwareDevice || options.Hardware.BaudRate != 19200 {
t.Fatal(options)
}
a.settings.live.Presentation.Speech = selected
a.settings.saveDraft.Presentation.Speech = selected
a.settings.resetBase.Presentation.Speech = selected
before := a.effective_settings()
if result := request_selection(t, a, "SET speech#voice=Unsupported;speech#rate=0.25;speech#enabled=False"); result == "" || !reflect.DeepEqual(a.effective_settings(), before) || !reflect.DeepEqual(a.settings.saveDraft, before) {
t.Fatal("partial hardware assignment", result, a.effective_settings())
}
if result := request_selection(t, a, "SET speech#rate=0.5;speech#pitch=0.25;speech#volume=1"); result != "" || speech_parameters(a.effective_settings()) != (speech.Parameters{Rate: 0, Pitch: -50, Volume: 100}) {
t.Fatal(result, a.effective_settings())
}
if result := request_selection(t, a, "RESET"); result != "" || a.effective_settings().Presentation.Speech != selected {
t.Fatal("hardware RESET", result, a.effective_settings())
}
}
}
func TestMenuSelectionFailurePreservesDiskAndCommittedSaveSurvivesClose(t *testing.T) {
for _, failed := range []bool{true, false} {
t.Run(map[bool]string{true: "failed", false: "success"}[failed], func(t *testing.T) {
a, _, path := selection_application(t, true)
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
doc, err := config.Open_document(path, a.settings.live)
if err != nil {
t.Fatal(err)
}
draft := doc.Settings
draft.Presentation.Speech.Module = "fixture"
draft.Presentation.Speech.Language = "en-US"
draft.Presentation.Speech.Voice = "Base Voice"
started, release := make(chan struct{}), make(chan struct{})
a.prepareSpeech = func(config.Speech) (speech.Transport, error) {
close(started)
<-release
if failed {
return nil, errors.New("fixture unavailable")
}
return &preparedVoice{}, nil
}
var outcome error
if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
t.Fatal("save not admitted")
}
<-started
a.ui.close()
a.quick.command(command.NextQuickValue, &a.settings.live, a.voice)
close(release)
finish_persistence(t, a, failed)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if failed {
if outcome == nil || string(after) != string(before) || a.settings.live.Presentation.Speech.Module != "" {
t.Fatal("failure changed disk/live", outcome)
}
} else {
if outcome != nil || a.settings.live.Presentation.Speech.Voice != "Base Voice" || a.settings.live.Presentation.Speech.Rate != 10 || a.settings.resetBase.Presentation.Speech.Rate != 0 || a.settings.resetBase.Presentation.Speech.Voice != "Base Voice" {
t.Fatal("committed selection or newer numeric override lost", outcome, a.settings.live, a.settings.resetBase)
}
}
})
}
}
func TestFailedWriteClosesPreparedClientAndKeepsActiveSelection(t *testing.T) {
a, voice, path := selection_application(t, true)
doc, err := config.Open_document(path, a.settings.live)
if err != nil {
t.Fatal(err)
}
draft := doc.Settings
draft.Presentation.Speech.Module = "fixture"
prepared := &preparedVoice{}
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return prepared, nil }
if err := os.WriteFile(path, []byte("external edit"), 0600); err != nil {
t.Fatal(err)
}
var outcome error
if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
t.Fatal("not admitted")
}
finish_persistence(t, a, true)
if outcome == nil || !prepared.closed.Load() || len(voice.replaced) != 0 || a.settings.live.Presentation.Speech.Module != "" {
t.Fatal("failed write replaced voice or leaked candidate", outcome)
}
}
func TestSavedFileTruthSurvivesConnectionFailureAtCommit(t *testing.T) {
a, voice, path := selection_application(t, true)
doc, err := config.Open_document(path, a.settings.live)
if err != nil {
t.Fatal(err)
}
draft := doc.Settings
draft.Presentation.Speech.Module = "fixture"
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
voice.replacementError = errors.New("fixture connection lost")
var outcome error
if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
t.Fatal("not admitted")
}
finish_persistence(t, a, true)
saved, _, err := config.Load(path, nil)
if err != nil || saved.Presentation.Speech.Module != "fixture" || a.settings.resetBase.Presentation.Speech.Module != "fixture" || a.settings.saveDraft.Presentation.Speech.Module != "fixture" || a.settings.live.Presentation.Speech.Module != "" || !settings_committed(outcome) {
t.Fatal("disk truth lost after committed write", outcome, saved, a.settings)
}
}
func TestQuickVoiceCancelSaveAndStaleFeedback(t *testing.T) {
a, voice, path := selection_application(t, true)
a.quick.discovery = preferences.Discovery{Catalog: speech.Catalog{Modules: []string{"fixture"}, Voices: map[string][]speech.Voice{
"fixture": {{Name: "Base Voice", Language: "en-US"}},
}}}
started, release := make(chan struct{}), make(chan struct{})
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { close(started); <-release; return &preparedVoice{}, nil }
for range 3 {
a.quick.command(command.NextQuickEntry, &a.settings.live, a.voice)
}
a.quick.command(command.NextQuickValue, &a.settings.live, a.voice)
<-started
// Focus handoff invalidates presentation, not the admitted setting change.
a.spellSession++
before := len(voice.messages)
close(release)
finish_selection(t, a)
if a.settings.live.Presentation.Speech.Module != "fixture" || !a.quick.selectionChanged || len(voice.messages) != before {
t.Fatal("stale feedback or lost selection", voice.messages)
}
// Unsaved quick selection appears in the form; Cancel does not undo it.
a.open_settings()
receive_ui(t, a.ui)
if got := a.ui.session.Values()["presentation.speech.module"]; got.Choices[got.Selected].ID != "fixture" {
t.Fatal(got)
}
a.ui.close()
saved, _, err := config.Load(path, nil)
if err != nil || saved.Presentation.Speech.Module != "" {
t.Fatal("Cancel saved quick voice", saved, err)
}
a.save_settings()
finish_persistence(t, a, false)
saved, _, err = config.Load(path, nil)
if err != nil || saved.Presentation.Speech.Module != "fixture" {
t.Fatal("quick save lost module", saved, err)
}
// Startup recovery keeps the original choice in the draft/reset baseline.
original := saved
original.Presentation.Speech.Module = "missing"
effective := original.Presentation.Speech
effective.Module = ""
effective.Language = ""
effective.Voice = ""
fresh, err := new_application(a.ctx, &fakeSource{}, &fakeKeys{}, &fakeVoice{}, RunOptions{Settings: original, EffectiveSpeech: &effective})
if err != nil || fresh.settings.live.Presentation.Speech.Module != "" || fresh.settings_to_save().Presentation.Speech.Module != "missing" || fresh.settings.resetBase.Presentation.Speech.Module != "missing" {
t.Fatal("startup recovery erased saved choice", err)
}
}
func TestRemoteSelectionRetainsAdmittedTrackingIntent(t *testing.T) {
a, _, _ := selection_application(t, true)
started, release := make(chan struct{}), make(chan struct{})
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { close(started); <-release; return &preparedVoice{}, nil }
reply := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING SET speech#module=fixture;focus#cursor=True", Reply: reply})
<-started
a.engine.Configure_tracking("highlight")
close(release)
finish_selection(t, a)
if response := <-reply; response != "" || a.engine.Tracking_mode() != "cursor" || a.settings.live.Presentation.Speech.Module != "fixture" {
t.Fatal("admitted selection became invalid during preparation", response, a.effective_settings())
}
}