Files
cthulhu/internal/app/preferences_feedback_test.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)
}
})
}
}