Files
cthulhu/internal/app/speech_worker_test.go

413 lines
13 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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())
}
}