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