Files
fenrir/internal/app/hardware_form_test.go

213 lines
7.0 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"
"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)
}
}