Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,412 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user