// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/preferences" "git.stormux.org/storm/fenrir/internal/speech" ) func focus_ui_control(t *testing.T, ui *internalUI, id string) { t.Helper() for range 100 { if control, ok := ui.session.Focused_control(); ok && control.ID == id { return } ui.handle(interaction.Key{Name: "tab"}) } t.Fatal("control not reachable", id) } func hardware_form_application(t *testing.T) (*application, string, *fakeVoice) { t.Helper() path := filepath.Join(t.TempDir(), "settings.toml") if err := config.Save(path, config.Defaults()); err != nil { t.Fatal(err) } voice := &fakeVoice{} a := settings_ui_application(t, &fakeKeys{}, voice, path) a.ui.open_settings(a.settings.live, path) receive_ui(t, a.ui) t.Cleanup(func() { a.ui.close(); a.ui.testWorkers.Wait() }) focus_ui_control(t, a.ui, "speech.driver") a.ui.handle(interaction.Key{Name: "down"}) focus_ui_control(t, a.ui, "speech.hardware_device") a.ui.handle(interaction.Key{Text: "/dev/fixture-synth"}) focus_ui_control(t, a.ui, "speech.hardware_baud_rate") a.ui.handle(interaction.Key{Name: "end"}) return a, path, voice } func TestHardwarePreviewUsesUnsavedDraftAndSaveKeepsActiveDriver(t *testing.T) { a, path, _ := hardware_form_application(t) before, _ := os.ReadFile(path) requested, stopped := make(chan config.Speech, 2), make(chan struct{}, 2) a.ui.testSpeech = func(ctx context.Context, selected, current config.Speech) error { if current.Driver != "speech-dispatcher" { return errors.New("wrong active driver") } requested <- selected <-ctx.Done() stopped <- struct{}{} return ctx.Err() } focus_ui_control(t, a.ui, "speech.test") a.ui.handle(interaction.Key{Name: "enter"}) select { case selected := <-requested: if selected.Driver != "dectalk" || selected.HardwareDevice != "/dev/fixture-synth" || selected.HardwareBaudRate != 115200 { t.Fatal(selected) } case <-time.After(time.Second): t.Fatal("preview did not start") } if after, _ := os.ReadFile(path); !reflect.DeepEqual(after, before) { t.Fatal("test saved settings") } if a.settings.live.Presentation.Speech.Driver != "speech-dispatcher" || !a.ui.active { t.Fatal("test replaced active driver or closed menu") } focus_ui_control(t, a.ui, "speech.hardware_device") a.ui.handle(interaction.Key{Ctrl: true, Text: "a"}) a.ui.handle(interaction.Key{Text: "/dev/fixture-updated"}) select { case <-stopped: case <-time.After(time.Second): t.Fatal("editing did not cancel preview") } focus_ui_control(t, a.ui, "ok") a.ui.handle(interaction.Key{Name: "enter"}) finish_persistence(t, a, false) saved, _, err := config.Load(path, nil) if err != nil || saved.Presentation.Speech.HardwareDevice != "/dev/fixture-updated" || saved.Presentation.Speech.HardwareBaudRate != 115200 { t.Fatal(saved, err) } if a.settings.live.Presentation.Speech.Driver != "speech-dispatcher" { t.Fatal("save replaced active driver") } } func TestHardwarePreviewFailureAndCloseDoNotWriteOrDeliverStaleFeedback(t *testing.T) { a, path, voice := hardware_form_application(t) before, _ := os.ReadFile(path) a.ui.testSpeech = func(context.Context, config.Speech, config.Speech) error { return errors.New("fixture port unavailable") } focus_ui_control(t, a.ui, "speech.test") a.ui.handle(interaction.Key{Name: "enter"}) receive_ui(t, a.ui) if got := voice.messages[len(voice.messages)-1]; !strings.Contains(got, "Voice test failed") || !strings.Contains(got, "fixture port unavailable") { t.Fatal(got) } started, release := make(chan struct{}), make(chan struct{}) a.ui.testSpeech = func(ctx context.Context, _, _ config.Speech) error { close(started) <-ctx.Done() <-release return errors.New("stale fixture failure") } a.ui.handle(interaction.Key{Name: "enter"}) select { case <-started: case <-time.After(time.Second): t.Fatal("preview not started") } a.ui.close() close(release) a.ui.testWorkers.Wait() for len(a.ui.updates) > 0 { receive_ui(t, a.ui) } if strings.Contains(strings.Join(voice.messages, "|"), "stale fixture failure") { t.Fatal("late failure escaped closed menu", voice.messages) } if after, _ := os.ReadFile(path); !reflect.DeepEqual(before, after) { t.Fatal("failed/canceled test wrote settings") } } type previewVoice struct { fakeVoice previews chan speech.Parameters } func (v *previewVoice) Say_with_parameters(_ string, parameters speech.Parameters) { v.previews <- parameters } func TestHardwarePreviewReusesOwnedConnectionAndNormalizesDefaultBaud(t *testing.T) { voice := &previewVoice{previews: make(chan speech.Parameters, 1)} ui := new_internal_ui(context.Background(), &fakeKeys{}, &echoSpeaker{Speaker: voice}) current := config.Defaults().Presentation.Speech current.Driver, current.HardwareDevice = "dectalk", "/dev/fixture-owned" selected := current selected.HardwareBaudRate, selected.Rate = 9600, 20 if err := ui.test_synthesizer(context.Background(), selected, current); err != nil { t.Fatal(err) } select { case parameters := <-voice.previews: if parameters.Rate != 20 { t.Fatal(parameters) } default: t.Fatal("owned connection was not used") } if len(voice.parameters) != 0 { t.Fatal("preview changed persistent live parameters") } } func TestDriverCatalogArrivesWithoutOverwritingAnotherDriverOrMovingFocus(t *testing.T) { a, _, _ := hardware_form_application(t) queries, releases := make(chan string, 4), make(chan struct{}, 4) a.ui.discoverChoices = func(settings config.Settings) preferences.Discovery { id := settings.Presentation.Speech.Driver queries <- id <-releases return preferences.Discovery{Driver: id, Catalog: speech.Catalog{Modules: []string{"fixture-module"}}} } // Reopen so the first catalog belongs to hardware, then discover Speech // Dispatcher only when selected. No connection is opened on the input loop. a.ui.close() base := a.settings.live base.Presentation.Speech.Driver, base.Presentation.Speech.HardwareDevice = "dectalk", "/dev/fixture-synth" go func() { releases <- struct{}{} }() a.ui.open_settings(base, "") receive_ui(t, a.ui) <-queries focus_ui_control(t, a.ui, "speech.driver") a.ui.handle(interaction.Key{Name: "home"}) select { case id := <-queries: if id != "speech-dispatcher" { t.Fatal(id) } case <-time.After(time.Second): t.Fatal("new driver catalog not requested") } a.ui.handle(interaction.Key{Name: "down"}) releases <- struct{}{} receive_ui(t, a.ui) if !a.ui.session.Values()["presentation.speech.module"].Hidden { t.Fatal("stale catalog changed driver visibility") } a.ui.handle(interaction.Key{Name: "home"}) control, _ := a.ui.session.Focused_control() if control.ID != "speech.driver" || len(a.ui.session.Values()["presentation.speech.module"].Choices) != 2 { t.Fatal("catalog/focus not restored", control) } }