Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"reflect"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestPauseChunksInterruptRemainingAnnouncement(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()) })
presenter := eventPresenter{state: &eventLoopState{},
logger: slog.New(slog.DiscardHandler), speech: worker}
presenter.state.presentationOptions = presentation.DefaultOptions()
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Save, draft", Role: "button", Description: "Save document"}))
close(expectSpeechCall(t, speaker, "cancel").done)
first := expectSpeechCall(t, speaker, "Save, draft")
worker.InterruptForUser()
close(expectSpeechCall(t, speaker, "cancel").done)
worker.Enqueue(speechRequest{Text: "New focus"})
close(first.done)
close(expectSpeechCall(t, speaker, "cancel").done) // Settle the interrupted call.
close(expectSpeechCall(t, speaker, "cancel").done) // Begin the new announcement.
close(expectSpeechCall(t, speaker, "New focus").done)
}
func TestPauseChunksStopOnBackendFailure(t *testing.T) {
failure := errors.New("speech failed")
speaker := &fakeSpeaker{speakError: failure}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.history = newSpeechHistory(10)
spoken := presentation.FocusSpeechWithOptions(atspi.Snapshot{Name: "Save", Role: "button"}, presentation.DefaultOptions())
err := worker.deliver(context.Background(), speechRequest{Text: spoken.Text, Chunks: spoken.Chunks})
if !errors.Is(err, failure) {
t.Fatalf("delivery error = %v", err)
}
if got := worker.history.snapshot(); !reflect.DeepEqual(got, []string{"Save"}) {
t.Fatalf("undelivered chunks entered history: %v", got)
}
}
func TestPauseChunksPreservePresentationDecorations(t *testing.T) {
settings := config.DefaultSettings()
settings.EnableSound = false
settings.Reading.PresentTooltips = true
live := &liveSettings{}
live.Store(settings)
options := presentationSettingsFromConfig(settings, true)
logger := slog.New(slog.DiscardHandler)
for _, test := range []struct {
name string
present func(*fakeSpeechQueue)
want []string
}{
{"window title", func(queue *fakeSpeechQueue) {
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{Name: "Save", Role: "button"})
event.WindowTitle = "Save, button"
presenter.speak(event)
}, []string{"Save, button", "Save", "button"}},
{"table", func(queue *fakeSpeechQueue) {
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
event := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/cell")
event.Source.Name, event.Source.Role, event.Source.GUITable = "One, Two", "table cell", true
event.Source.TableName, event.Source.TablePath = "Example, table", "/table"
event.WindowDescription = "Table help, details"
presenter.speak(event)
}, []string{"Example, table", "One, Two", "Table help, details"}},
{"prompt", func(queue *fakeSpeechQueue) {
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
event := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/button")
event.Source.Name, event.Source.Role = "Save", "button"
event.WindowTitle, event.WindowMessage = "Question, details", "Save draft, or discard?"
presenter.speak(event)
}, []string{"Question, details", "Save draft, or discard?"}},
{"browser wrapping", func(queue *fakeSpeechQueue) {
presentDocumentResult(logger, queue, review.Result{Moved: true, DocumentWrapped: true,
DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: "Read, draft", Role: "link"}},
CaretErr: errors.New("caret unavailable")}, options)
}, []string{"Read, draft", "link", "Wrapping to top.", "Could not move the browser caret."}},
{"tooltip", func(queue *fakeSpeechQueue) {
handler := accessibilityEventHandler{state: &eventLoopState{}, speech: queue, live: live}
event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Name: "Save, draft", Role: "tool tip"})
event.Detail1 = 1
handler.handleTooltip(event)
}, []string{"Save, draft", "tool tip"}},
{"object review", func(queue *fakeSpeechQueue) {
presentObjectReviewResult(logger, queue, review.Result{Moved: true,
Position: review.Position{Current: atspi.Snapshot{Name: "Read, draft", Role: "link"}}}, options)
}, []string{"Read, draft", "link"}},
} {
t.Run(test.name, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
test.present(queue)
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(logger, speaker)
if len(queue.requests) != 1 {
t.Fatal("presentation did not queue one announcement")
}
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(speaker.parts, test.want) {
t.Fatalf("backend chunks = %q, want %q", speaker.parts, test.want)
}
})
}
}
type PauseWaitSpeaker struct {
spellingTestSpeaker
voices []string
progress []int
line int
}
func (speaker *PauseWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, began func()) error {
if began != nil {
began()
}
speaker.voices = append(speaker.voices, voice)
speaker.progress = append(speaker.progress, speaker.line)
return speaker.Speak(ctx, text)
}
func (speaker *PauseWaitSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
return speaker.SpeakVoiceAndWait(ctx, text, "default", began)
}
func TestPauseChunksSayAllKeepsLineProgressAndVoice(t *testing.T) {
for _, enabled := range []bool{true, false} {
for _, style := range []string{"line", "sentence"} {
speaker := &PauseWaitSpeaker{line: -1}
settings := config.DefaultSettings()
settings.Reading.EnablePauseBreaks = enabled
settings.Reading.EnableIndentation = true
settings.SayAllStyle = style
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
lines := []atspi.DocumentLine{
{Contents: []atspi.DocumentContent{{Text: "First, sentence. Second!", RawText: " First, sentence. Second!",
Snapshot: atspi.Snapshot{Path: "/link", Role: "link", Visited: true}}}},
{Contents: []atspi.DocumentContent{{Text: "Next line", Snapshot: atspi.Snapshot{Path: "/text", Role: "text"}}}},
}
var callbacks []int
result := review.Result{ReadAllContext: context.Background(), ReadAllLines: lines,
ReadAllProgress: func(line int) { callbacks = append(callbacks, line); speaker.line = line }}
presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentationSettingsFromConfig(settings, true))
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.reading = func() config.ReadingSettings { return settings.Reading }
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
t.Fatal(err)
}
want := []string{"indentation 2 columns, First, sentence. Second!", "visited link", "indentation 0 columns, Next line"}
wantProgress := []int{0, 0, 1}
wantVoices := []string{"hyperlink", "hyperlink", "default"}
if !enabled {
want = []string{"indentation 2 columns, First, sentence. Second! visited link", "indentation 0 columns, Next line"}
wantProgress, wantVoices = []int{0, 1}, []string{"hyperlink", "default"}
}
if style == "sentence" {
want[0] = "indentation 2 columns, First, sentence."
second := "Second!"
if !enabled {
want = []string{want[0], second, "visited link", want[1]}
} else {
want = []string{want[0], second, want[1], want[2]}
}
wantProgress, wantVoices = []int{0, 0, 0, 1}, []string{"hyperlink", "hyperlink", "hyperlink", "default"}
}
if !reflect.DeepEqual(speaker.parts, want) || !reflect.DeepEqual(speaker.progress, wantProgress) ||
!reflect.DeepEqual(speaker.voices, wantVoices) || !reflect.DeepEqual(callbacks, []int{0, 1}) {
t.Fatalf("enabled=%v style=%s: speech=%v progress=%v voices=%v callbacks=%v", enabled, style,
speaker.parts, speaker.progress, speaker.voices, callbacks)
}
}
}
}