// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "bytes" "context" "errors" "fmt" "log/slog" "strings" "sync" "testing" "time" "git.stormux.org/storm/cthulhu/internal/logging" ) type blockingSpeaker struct { started chan struct{} once sync.Once } func (speaker *blockingSpeaker) Connect(context.Context) error { return nil } func (speaker *blockingSpeaker) Cancel(context.Context) error { return nil } func (speaker *blockingSpeaker) Close(context.Context) error { return nil } func (speaker *blockingSpeaker) Speak(ctx context.Context, _ string) error { speaker.once.Do(func() { close(speaker.started) }) <-ctx.Done() return ctx.Err() } func TestSpeechWorkerDeliversInOrder(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 2)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } worker.Enqueue(speechRequest{Text: "First", Role: "label"}) worker.Enqueue(speechRequest{Text: "Second", Role: "button"}) for _, want := range []string{"First", "Second"} { select { case got := <-speaker.spoken: if got != want { t.Fatalf("spoken text = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("timed out waiting for %q", want) } } if err := worker.Stop(context.Background()); err != nil { t.Fatalf("Stop: %v", err) } } func TestStartupSpeechSurvivesAutomaticFocusInterruption(t *testing.T) { speaker := &blockingSpeaker{started: make(chan struct{})} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.EnqueueStartup(speechRequest{Text: "Welcome", Cause: "startup"}) select { case <-speaker.started: case <-time.After(time.Second): t.Fatal("startup speech did not begin") } worker.Interrupt() // Automatic window focus must not cancel the welcome. worker.Enqueue(speechRequest{Text: "Focused control", Cause: "focus:"}) worker.mutex.Lock() retained := len(worker.pending) == 1 && worker.pending[0].Text == "Focused control" && worker.pending[0].Append worker.mutex.Unlock() if !retained { t.Fatal("automatic focus did not append after protected startup speech") } worker.InterruptForUser() worker.mutex.Lock() cleared := len(worker.pending) == 0 && worker.startupUntil.IsZero() worker.mutex.Unlock() if !cleared { t.Fatal("user key did not end startup protection and clear queued focus") } } func TestSpeechWorkerEnqueueAndWaitReportsDelivery(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 1)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) delivered := make(chan error, 1) go func() { delivered <- worker.EnqueueAndWait(context.Background(), speechRequest{Text: shutdownMessages[0]}) }() if got := <-speaker.spoken; got != shutdownMessages[0] { t.Fatalf("spoken text = %q, want %q", got, shutdownMessages[0]) } if err := <-delivered; err != nil { t.Fatalf("EnqueueAndWait: %v", err) } } func TestEnqueueAndWaitTimeoutCancelsDelivery(t *testing.T) { speaker := &blockingSpeaker{started: make(chan struct{})} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } defer worker.Stop(context.Background()) requestContext, cancelRequest := context.WithCancel(context.Background()) defer cancelRequest() delivered := make(chan error, 1) go func() { delivered <- worker.EnqueueAndWait(requestContext, speechRequest{Text: "Blocked"}) }() select { case <-speaker.started: case <-time.After(time.Second): t.Fatal("timed out waiting for speech delivery to start") } worker.mutex.Lock() activeDone := worker.activeDone worker.mutex.Unlock() if activeDone == nil { t.Fatal("active speech request was not started") } cancelRequest() if err := <-delivered; !errors.Is(err, context.Canceled) { t.Fatalf("EnqueueAndWait error = %v, want canceled", err) } select { case <-activeDone: case <-time.After(time.Second): t.Fatal("timed-out speech request was not canceled") } } func TestSpeechWorkerBoundsQueueAndKeepsNewestRequests(t *testing.T) { speaker := &blockingSpeaker{started: make(chan struct{})} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } worker.Enqueue(speechRequest{Text: "Blocking"}) select { case <-speaker.started: case <-time.After(time.Second): t.Fatal("timed out waiting for blocking speech call") } for index := 0; index < speechQueueCapacity+2; index++ { worker.Enqueue(speechRequest{Text: fmt.Sprintf("Pending %d", index)}) } worker.mutex.Lock() if got := len(worker.pending); got != speechQueueCapacity { worker.mutex.Unlock() t.Fatalf("pending requests = %d, want %d", got, speechQueueCapacity) } if got := worker.overflow; got != 2 { worker.mutex.Unlock() t.Fatalf("overflow = %d, want 2", got) } oldest := worker.pending[0].Text worker.mutex.Unlock() if oldest != "Pending 2" { t.Fatalf("oldest retained request = %q, want %q", oldest, "Pending 2") } stopContext, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := worker.Stop(stopContext); err != nil { t.Fatalf("Stop: %v", err) } worker.mutex.Lock() reported := worker.reported worker.mutex.Unlock() if reported != 2 { t.Fatalf("reported overflow = %d, want 2", reported) } } type speechCall struct { text string done chan struct{} } type controlledSpeaker struct { calls chan speechCall } func (speaker *controlledSpeaker) Connect(context.Context) error { return nil } func (speaker *controlledSpeaker) Close(context.Context) error { return nil } func (speaker *controlledSpeaker) Cancel(ctx context.Context) error { return speaker.call(ctx, "cancel") } func (speaker *controlledSpeaker) Speak(ctx context.Context, text string) error { return speaker.call(ctx, text) } func (speaker *controlledSpeaker) call(ctx context.Context, text string) error { call := speechCall{text: text, done: make(chan struct{})} select { case speaker.calls <- call: case <-ctx.Done(): return ctx.Err() } select { case <-call.done: return nil case <-ctx.Done(): return ctx.Err() } } func expectSpeechCall(t *testing.T, speaker *controlledSpeaker, want string) speechCall { t.Helper() select { case call := <-speaker.calls: if call.text != want { t.Fatalf("call = %q, want %q", call.text, want) } return call case <-time.After(time.Second): t.Fatalf("waiting for %q", want) return speechCall{} } } func TestInterruptDropsPendingAndOrdersNextSpeech(t *testing.T) { speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { worker.Stop(context.Background()) }) worker.Enqueue(speechRequest{Text: "Active"}) close(expectSpeechCall(t, speaker, "cancel").done) active := expectSpeechCall(t, speaker, "Active") worker.Enqueue(speechRequest{Text: "Discarded"}) worker.Interrupt() interrupt := expectSpeechCall(t, speaker, "cancel") // Flooding interrupts is bounded and never waits for the backend. for range 1000 { worker.Interrupt() } worker.Enqueue(speechRequest{Text: "Next"}) close(interrupt.done) close(active.done) // One follow-up cancellation closes the acceptance race; a coalesced // second interrupt may add another. No stale request may reach Speak. for { select { case call := <-speaker.calls: if call.text != "cancel" && call.text != "Next" { t.Fatalf("unexpected %q", call.text) } close(call.done) if call.text == "Next" { if err := worker.Stop(context.Background()); err != nil { t.Fatal(err) } return } case <-time.After(time.Second): t.Fatal("next speech did not resume") } } } type stubbornCancelSpeaker struct { blockingSpeaker cancelStarted chan struct{} release chan struct{} } func (speaker *stubbornCancelSpeaker) Cancel(context.Context) error { close(speaker.cancelStarted) <-speaker.release return nil } func TestStopWaitsForCancellationEvenAfterDeliveryExits(t *testing.T) { speaker := &stubbornCancelSpeaker{cancelStarted: make(chan struct{}), release: make(chan struct{})} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { close(speaker.release); worker.Stop(context.Background()) }) worker.Interrupt() select { case <-speaker.cancelStarted: case <-time.After(time.Second): t.Fatal("cancel did not start") } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := worker.Stop(ctx); err == nil { t.Fatal("Stop reported success with cancellation blocked") } <-worker.runDone if err := worker.Stop(ctx); err == nil { t.Fatal("repeated Stop forgot cancellation worker") } } type orderedSpeaker struct { calls []string } func (speaker *orderedSpeaker) Connect(context.Context) error { return nil } func (speaker *orderedSpeaker) Close(context.Context) error { return nil } func (speaker *orderedSpeaker) Cancel(context.Context) error { speaker.calls = append(speaker.calls, "cancel") return nil } func (speaker *orderedSpeaker) Speak(_ context.Context, text string) error { speaker.calls = append(speaker.calls, text) return nil } func TestTerminalOutputAndPromptDoNotCancelEachOther(t *testing.T) { speaker := &orderedSpeaker{} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) for _, request := range []speechRequest{ {Text: "Command output", Role: "terminal", Append: true}, {Text: "Prompt", Role: "terminal", Append: true}, {Text: "Save", Role: "button"}, } { if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } } if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt cancel Save]" { t.Fatalf("speech order = %s", got) } } func TestSpeechDeliveryDiagnosticsIncludeUtteranceAndPolicy(t *testing.T) { var output bytes.Buffer logger := logging.New(&output, logging.Options{Level: slog.LevelDebug}) worker := newSpeechWorker(logger, &orderedSpeaker{}) request := speechRequest{ Text: "Terminal output with Café and 猫.", Role: "terminal", Cause: "object:text-changed:insert", Append: true, } if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } logText := output.String() for _, expected := range []string{ `"cause":"object:text-changed:insert"`, `"append":true`, `"interrupt_previous":false`, `"utterance":"Terminal output with Café and 猫."`, } { if !strings.Contains(logText, expected) { t.Fatalf("diagnostics %q do not contain %q", logText, expected) } } } func TestNormalSpeechDeliveryDiagnosticsOmitUtterance(t *testing.T) { var output bytes.Buffer logger := logging.New(&output, logging.Options{Level: slog.LevelInfo}) worker := newSpeechWorker(logger, &orderedSpeaker{}) request := speechRequest{Text: "Terminal output", Role: "terminal"} if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } if strings.Contains(output.String(), request.Text) { t.Fatalf("normal diagnostics exposed speech text: %q", output.String()) } } func TestSpeechDeliveryDiagnosticsProtectPasswordText(t *testing.T) { var output bytes.Buffer worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), &orderedSpeaker{}) request := speechRequest{Text: "generic-secret", Role: "password text"} if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } if strings.Contains(output.String(), request.Text) || !strings.Contains(output.String(), `"utterance":"[REDACTED]"`) { t.Fatalf("protected speech diagnostics: %s", output.String()) } }