199 lines
8.3 KiB
Go
199 lines
8.3 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
"git.stormux.org/storm/cthulhu/internal/speech"
|
|
)
|
|
|
|
type confirmationSpeaker struct{ controlledSpeaker }
|
|
|
|
func (speaker *confirmationSpeaker) SpeakAndWait(ctx context.Context, text string, _ func()) error {
|
|
return speaker.call(ctx, text)
|
|
}
|
|
|
|
func TestPreferencesConfirmationSurvivesAutomaticFocusUntilSpeechEnds(t *testing.T) {
|
|
speaker := &confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 10)}}
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { worker.Stop(context.Background()) })
|
|
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
|
|
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
|
|
worker.Interrupt() // The window activated after OK must not cancel feedback.
|
|
worker.Enqueue(speechRequest{Text: "Window title"})
|
|
worker.Enqueue(speechRequest{Text: "Focused control"})
|
|
close(confirmation.done) // Represents the synth's END event, not SPEAK acknowledgement.
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Window title").done)
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control").done)
|
|
worker.Enqueue(speechRequest{Text: "Later output"})
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later output").done)
|
|
}
|
|
|
|
func TestPreferencesConfirmationAllowsUserInterruption(t *testing.T) {
|
|
speaker := &confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 10)}}
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { worker.Stop(context.Background()) })
|
|
display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
|
display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1})
|
|
display.FlashConfirmation("Settings applied", config.BrailleOptions{FlashMessages: true, FlashPersistent: true})
|
|
display.EnqueueFrame(braille.Frame{Text: "Returned focus", Cursor: -1})
|
|
gate := &focusGate{braille: display}
|
|
output := gatedSpeech{speech: worker, gate: gate}
|
|
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
|
|
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
|
|
worker.Enqueue(speechRequest{Text: "Discarded focus"})
|
|
if !output.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 101}) {
|
|
t.Fatal("new user input did not interrupt")
|
|
}
|
|
if display.current().Text != "Returned focus" {
|
|
t.Fatal("new user input did not dismiss braille confirmation")
|
|
}
|
|
worker.mutex.Lock()
|
|
cleared := len(worker.pending) == 0 && worker.confirmationID == 0
|
|
worker.mutex.Unlock()
|
|
if !cleared {
|
|
t.Fatal("user input retained speech confirmation or queued focus")
|
|
}
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
|
|
close(confirmation.done)
|
|
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
|
|
}
|
|
|
|
func TestPreferencesBrailleConfirmationRestoresLatestFocusOnExpiry(t *testing.T) {
|
|
display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
|
display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1})
|
|
if !display.FlashConfirmation("Settings applied", config.BrailleOptions{FlashMessages: true, FlashTime: 20}) {
|
|
t.Fatal("confirmation was rejected")
|
|
}
|
|
<-display.wake
|
|
display.EnqueueFrame(braille.Frame{Text: "Window title", Cursor: -1})
|
|
display.EnqueueFrame(braille.Frame{Text: "Latest focus", Cursor: 3})
|
|
if display.current().Text != "Settings applied" {
|
|
t.Fatal("automatic focus replaced braille confirmation")
|
|
}
|
|
select {
|
|
case <-display.wake:
|
|
if frame := display.current(); frame.Text != "Latest focus" || frame.Cursor != 3 {
|
|
t.Fatalf("restored frame = %+v", frame)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("confirmation did not expire")
|
|
}
|
|
}
|
|
|
|
func TestPreferencesConfirmationRetiresOnScreenLock(t *testing.T) {
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
|
worker.running = true
|
|
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
|
|
display := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
|
options := config.BrailleOptions{FlashMessages: true, FlashPersistent: true}
|
|
display.EnqueueFrame(braille.Frame{Text: "Apply", Cursor: -1})
|
|
display.FlashConfirmation("Settings applied", options)
|
|
worker.SetSecureInputActive(true)
|
|
display.SetSecureInputActive(true)
|
|
if len(worker.pending) != 0 || worker.confirmationID != 0 || display.protectedFlash || display.current().Text != "" {
|
|
t.Fatal("screen lock retained preferences confirmation")
|
|
}
|
|
if display.FlashConfirmation("Late confirmation", options) || display.protectedFlash {
|
|
t.Fatal("screen lock accepted late preferences confirmation")
|
|
}
|
|
}
|
|
|
|
func TestPreferencesConfirmationAllowsCommandBeforeRecordedInput(t *testing.T) {
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
|
worker.running = true
|
|
worker.ProtectCommand(100)
|
|
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
|
|
worker.ProtectCommand(99)
|
|
if worker.confirmationID == 0 {
|
|
t.Fatal("older command interrupted confirmation")
|
|
}
|
|
worker.ProtectCommand(101)
|
|
if worker.confirmationID != 0 || len(worker.pending) != 0 {
|
|
t.Fatal("new command did not interrupt confirmation")
|
|
}
|
|
worker.Enqueue(speechRequest{Text: "Command result"})
|
|
if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 1 {
|
|
t.Fatal("delayed initiating input discarded command result")
|
|
}
|
|
}
|
|
|
|
func TestPreferencesApplyAnnouncesBeforeReplyAndPreservesSaveFailure(t *testing.T) {
|
|
for _, saveFailure := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "saved", true: "save failure"}[saveFailure], func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error {
|
|
if saveFailure {
|
|
return os.Mkdir(settings.Path, 0700) // Runtime succeeds; subsequent disk save fails.
|
|
}
|
|
return nil
|
|
}))
|
|
messages := make(chan string, 1)
|
|
worker.announceApply = func(message string) { messages <- message }
|
|
reader, writer := io.Pipe()
|
|
defer reader.Close()
|
|
defer writer.Close()
|
|
stopIO := context.AfterFunc(ctx, func() { reader.CloseWithError(ctx.Err()); writer.CloseWithError(ctx.Err()) })
|
|
defer stopIO()
|
|
requests := make(chan preferences.Request)
|
|
readDone := make(chan error, 1)
|
|
done := make(chan error, 1)
|
|
go func() { done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) }()
|
|
values := preferences.FromSettings(settings)
|
|
rate := 65.0
|
|
values.Rate = &rate
|
|
requests <- preferences.Request{Values: values, Save: true, Close: true}
|
|
// The pipe cannot write the reply until we start reading: feedback
|
|
// must already be queued so the helper's close cannot overtake it.
|
|
var message string
|
|
select {
|
|
case message = <-messages:
|
|
case <-ctx.Done():
|
|
t.Fatal("feedback was not queued before the helper reply")
|
|
}
|
|
var response preferences.Response
|
|
if err := json.NewDecoder(reader).Decode(&response); err != nil || !response.Accepted {
|
|
t.Fatalf("apply response = %+v, %v", response, err)
|
|
}
|
|
if saveFailure {
|
|
if !strings.Contains(message, "could not save") || response.Close {
|
|
t.Fatalf("save failure was hidden: message=%q, close=%t", message, response.Close)
|
|
}
|
|
} else if message != "Settings applied" || !response.Close {
|
|
t.Fatalf("success: message=%q, close=%t", message, response.Close)
|
|
}
|
|
readDone <- io.EOF
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|