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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user