413 lines
13 KiB
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())
|
|
}
|
|
}
|