318 lines
13 KiB
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())
|
|
}
|
|
}
|