// 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) } }) } }