// 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()) } }