425 lines
17 KiB
Go
425 lines
17 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 (
|
|
"context"
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
"git.stormux.org/storm/cthulhu/internal/sound"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDocumentPresentation(t *testing.T) {
|
|
heading := atspi.NewSnapshot("/h", "Reading section", "heading", "", "")
|
|
heading.HeadingLevel = 2
|
|
link := atspi.NewSnapshot("/l", "Example", "link", "", "")
|
|
link.Visited = true
|
|
for _, tc := range []struct {
|
|
result review.Result
|
|
want string
|
|
}{
|
|
{review.Result{Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2"},
|
|
{review.Result{Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: link}}, "Example, visited link"},
|
|
{review.Result{Moved: true, DocumentWrapped: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2. Wrapping to top."},
|
|
{review.Result{Moved: true, DocumentBackwards: true, DocumentWrapped: true, DocumentPosition: review.DocumentPosition{Snapshot: heading}}, "Reading section, heading level 2. Wrapping to bottom."},
|
|
{review.Result{DocumentTarget: review.DocumentHeading}, "No more headings."},
|
|
{review.Result{DocumentTarget: review.DocumentLink}, "No more links."},
|
|
{review.Result{DocumentTarget: review.DocumentButton}, "No more buttons."},
|
|
{review.Result{DocumentTarget: review.DocumentCheckBox}, "No more check boxes."},
|
|
{review.Result{DocumentTarget: review.DocumentList}, "No more lists."},
|
|
{review.Result{DocumentTarget: review.DocumentSeparator}, "No more separators."},
|
|
{review.Result{DocumentTarget: review.DocumentHeading, DocumentLevel: 3}, "No more headings at level 3."},
|
|
{review.Result{DocumentTarget: review.DocumentBlockQuote}, "No more blockquotes."},
|
|
{review.Result{DocumentTarget: review.DocumentComboBox}, "No more combo boxes."},
|
|
{review.Result{DocumentTarget: review.DocumentEntry}, "No more entries."},
|
|
{review.Result{DocumentTarget: review.DocumentFormField}, "No more form fields."},
|
|
{review.Result{DocumentTarget: review.DocumentImage}, "No more images."},
|
|
{review.Result{DocumentTarget: review.DocumentLandmark}, "No landmark found."},
|
|
{review.Result{DocumentTarget: review.DocumentListItem}, "No more list items."},
|
|
{review.Result{DocumentTarget: review.DocumentLiveRegion}, "No more live regions."},
|
|
{review.Result{DocumentTarget: review.DocumentParagraph}, "No more paragraphs."},
|
|
{review.Result{DocumentTarget: review.DocumentRadioButton}, "No more radio buttons."},
|
|
{review.Result{DocumentTarget: review.DocumentTable}, "No more tables."},
|
|
{review.Result{DocumentTarget: review.DocumentUnvisitedLink}, "No more unvisited links."},
|
|
{review.Result{DocumentTarget: review.DocumentVisitedLink}, "No more visited links."},
|
|
{review.Result{DocumentTarget: review.DocumentClickable}, "No more clickables."},
|
|
{review.Result{DocumentTarget: review.DocumentChunk}, "No more large objects."},
|
|
{review.Result{Err: atspi.ErrNotInDocument}, "Not in web document content."},
|
|
{review.Result{Err: atspi.ErrStaleObject}, "Could not find current location."},
|
|
} {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)}
|
|
presentDocumentResult(slog.New(slog.DiscardHandler), queue, tc.result, presentation.Options{})
|
|
if got := <-queue.spoken; got != tc.want {
|
|
t.Fatalf("got %q want %q", got, tc.want)
|
|
}
|
|
select {
|
|
case <-queue.interrupt:
|
|
default:
|
|
t.Fatal("did not interrupt speech")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLinkedHeadingPresentation(t *testing.T) {
|
|
heading := atspi.NewSnapshot("/heading", "Linked heading", "heading", "", "")
|
|
heading.HeadingLevel = 2
|
|
link := atspi.NewSnapshot("/link", "Linked heading", "link", "", "")
|
|
link.Visited = true
|
|
for _, mode := range []struct{ name, want string }{
|
|
{"sound_and_speech", "Linked heading, heading level 2, visited link"},
|
|
{"sound_only", "Linked heading, heading level 2"},
|
|
{"speech_only", "Linked heading, heading level 2, visited link"},
|
|
} {
|
|
t.Run(mode.name, func(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)}
|
|
presentDocumentResult(slog.New(slog.DiscardHandler), queue, review.Result{
|
|
Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: heading, LinkedHeading: &link},
|
|
}, presentation.Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: mode.name})
|
|
if got := <-speech.spoken; got != mode.want {
|
|
t.Fatalf("speech = %q, want %q", got, mode.want)
|
|
}
|
|
if mode.name != "speech_only" {
|
|
if cue := <-queue.sounds; cue.Name != "link_visited" {
|
|
t.Fatalf("cue = %+v", cue)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentPresentationUsesObjectSoundPolicy(t *testing.T) {
|
|
toggle := atspi.NewSnapshot("/toggle", "Power", "toggle button", "", "")
|
|
toggle.Checked = true
|
|
toggle.SoundGeometry = atspi.SoundGeometry{
|
|
ObjectKnown: true, ReferenceKnown: true,
|
|
Object: atspi.Rectangle{X: 100, Y: 10, Width: 10, Height: 10},
|
|
Reference: atspi.Rectangle{X: 100, Y: 0, Width: 1000, Height: 600},
|
|
}
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)}
|
|
options := presentation.Options{
|
|
EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true,
|
|
RoleSoundPresentation: "sound_only", SpatializeObjectSounds: true,
|
|
}
|
|
presentDocumentResult(slog.New(slog.DiscardHandler), queue, review.Result{
|
|
Moved: true, DocumentPosition: review.DocumentPosition{Snapshot: toggle},
|
|
}, options)
|
|
if got := <-speech.spoken; got != "Power" {
|
|
t.Fatalf("sound-only structural speech = %q", got)
|
|
}
|
|
for _, name := range []string{"button", "togglebutton_checked"} {
|
|
cue := <-queue.sounds
|
|
if cue.Name != name || cue.Pan != -1 {
|
|
t.Fatalf("structural cue = %#v, want %s panned left", cue, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentReviewCommandMapping(t *testing.T) {
|
|
cases := []struct {
|
|
command input.Command
|
|
target review.DocumentTarget
|
|
level int
|
|
back bool
|
|
}{
|
|
{input.DocumentNextHeadingAtLevel3, review.DocumentHeading, 3, false},
|
|
{input.DocumentPreviousHeadingAtLevel3, review.DocumentHeading, 3, true},
|
|
{input.DocumentNextComboBox, review.DocumentComboBox, 0, false},
|
|
{input.DocumentPreviousComboBox, review.DocumentComboBox, 0, true},
|
|
{input.DocumentNextChunk, review.DocumentChunk, 0, false},
|
|
{input.DocumentPreviousClickable, review.DocumentClickable, 0, true},
|
|
}
|
|
for _, tc := range cases {
|
|
target, level, back := documentReviewCommand(tc.command)
|
|
if target != tc.target || level != tc.level || back != tc.back {
|
|
t.Fatalf("%s: target=%v level=%d back=%v", tc.command, target, level, back)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentNavigationReachesEventLoopSpeech(t *testing.T) {
|
|
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
|
doc := atspi.NewSnapshot("/doc", "Page", "document web", "", "")
|
|
doc.BusName = ":1.42"
|
|
heading := atspi.NewSnapshot("/heading", "Section", "heading", "", "")
|
|
heading.BusName = ":1.42"
|
|
heading.HeadingLevel = 2
|
|
tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{
|
|
{Snapshot: doc, Children: []atspi.ObjectID{id("/heading")}}, {Snapshot: heading},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree}
|
|
worker, err := review.NewWorker(adapter, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if err := worker.Start(ctx); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
defer cancel()
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 4), interrupt: make(chan struct{}, 4)}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0)
|
|
}()
|
|
expect := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != want {
|
|
t.Fatalf("got %q want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("speech missing")
|
|
}
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, doc)
|
|
expect("Page, document web")
|
|
worker.MoveDocument(review.DocumentHeading, 0, false)
|
|
expect("Section, heading level 2")
|
|
worker.MoveDocument(review.DocumentHeading, 0, false)
|
|
expect("Section, heading level 2. Wrapping to top.")
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestDocumentNavigationDiscardsPendingOldCaret(t *testing.T) {
|
|
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
|
doc := atspi.NewSnapshot("/doc", "Page", "document web", "", "")
|
|
doc.BusName = ":1.42"
|
|
link := atspi.NewSnapshot("/link", "Old link", "link", "", "")
|
|
link.BusName = ":1.42"
|
|
heading := atspi.NewSnapshot("/heading", "Next section", "heading", "", "")
|
|
heading.BusName = ":1.42"
|
|
tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{
|
|
{Snapshot: doc, Children: []atspi.ObjectID{id("/link"), id("/heading")}},
|
|
{Snapshot: link}, {Snapshot: heading},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
captureStarted := make(chan struct{}, 1)
|
|
releaseCapture := make(chan struct{})
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
adapter.capture = func(ctx context.Context) (*atspi.AccessibleTree, error) {
|
|
captureStarted <- struct{}{}
|
|
select {
|
|
case <-releaseCapture:
|
|
return tree, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
worker, err := review.NewWorker(adapter, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 100*time.Millisecond)
|
|
}()
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, link)
|
|
if got := <-queue.spoken; got != "Old link, link" {
|
|
t.Fatalf("focus speech = %q", got)
|
|
}
|
|
if err := worker.MoveDocument(review.DocumentHeading, 0, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-captureStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("document capture did not start")
|
|
}
|
|
oldCaret := link
|
|
oldCaret.Text = "Old line"
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldCaret)
|
|
// Receiving the unrelated event means the old caret reached its timer
|
|
// before the structural capture is allowed to finish.
|
|
unrelated := atspi.NewSnapshot("/unrelated", "", "static", "", "")
|
|
unrelated.BusName = ":1.99"
|
|
adapter.events <- atspi.NewEvent(atspi.DefunctStateEvent, unrelated)
|
|
close(releaseCapture)
|
|
select {
|
|
case got := <-queue.spoken:
|
|
if got != "Next section, heading" {
|
|
t.Fatalf("navigation speech = %q", got)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("navigation speech missing")
|
|
}
|
|
select {
|
|
case got := <-queue.spoken:
|
|
t.Fatalf("old caret replaced navigation speech: %q", got)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestDocumentResultAcceptsSelfNavigationFocusTransition(t *testing.T) {
|
|
previous := atspi.NewSnapshot("/previous", "Previous", "heading", "", "")
|
|
target := atspi.NewSnapshot("/target", "Target", "heading", "", "")
|
|
moved := review.Result{
|
|
Moved: true,
|
|
DocumentFocus: previous.ID(),
|
|
DocumentPosition: review.DocumentPosition{Snapshot: target},
|
|
}
|
|
if !documentResultMatchesCurrent(moved, previous) {
|
|
t.Fatal("result for unchanged focus was rejected")
|
|
}
|
|
if !documentResultMatchesCurrent(moved, target) {
|
|
t.Fatal("result for navigation target was rejected")
|
|
}
|
|
other := atspi.NewSnapshot("/other", "Other", "heading", "", "")
|
|
if documentResultMatchesCurrent(moved, other) {
|
|
t.Fatal("result for unrelated focus was accepted")
|
|
}
|
|
boundary := moved
|
|
boundary.Moved = false
|
|
if documentResultMatchesCurrent(boundary, target) {
|
|
t.Fatal("unmoved result was accepted as a navigation transition")
|
|
}
|
|
}
|
|
|
|
func TestCaretPresentationRetainsAncestorSemantics(t *testing.T) {
|
|
leaf := atspi.NewSnapshot("/text", "Entire longer label", "static", "", "")
|
|
link := atspi.NewSnapshot("/link", "Entire longer label", "link", "", "")
|
|
link.Visited = true
|
|
heading := atspi.NewSnapshot("/heading", "Entire heading", "heading", "", "")
|
|
heading.HeadingLevel = 3
|
|
for _, tc := range []struct {
|
|
command review.DocumentCaretCommand
|
|
text, want string
|
|
}{
|
|
{review.CaretNextLine, "Example", "Example, visited link, heading level 3"},
|
|
{review.CaretPreviousWord, "Example", "Example, visited link, heading level 3"},
|
|
{review.CaretNextCharacter, "E", "E"},
|
|
{review.CaretLineStart, "E", "E"},
|
|
} {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{
|
|
Moved: true, DocumentCaretCommand: tc.command, DocumentCaretText: tc.text,
|
|
DocumentPosition: review.DocumentPosition{Snapshot: leaf}, DocumentCaretContext: []atspi.Snapshot{leaf, link, heading},
|
|
}, presentation.DefaultOptions())
|
|
request := <-queue.requests
|
|
if request.Text != tc.want {
|
|
t.Fatalf("speech = %q, want %q", request.Text, tc.want)
|
|
}
|
|
wantCharacter := tc.command == review.CaretNextCharacter || tc.command == review.CaretLineStart
|
|
if request.Character != wantCharacter {
|
|
t.Fatalf("character voice = %v for command %v", request.Character, tc.command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTableCaretHeadersFollowSpeechVerbosity(t *testing.T) {
|
|
cell := atspi.NewSnapshot("/cell", "42", "table cell", "", "")
|
|
cell.TableRowText = "42, 18"
|
|
result := review.Result{
|
|
Moved: true, DocumentCaretCommand: review.TableRight,
|
|
DocumentCaretText: "Monday Total 42 Row 2, column 3.",
|
|
DocumentTablePrefix: "Monday Total", DocumentTableRowHeader: "Monday", DocumentTableColumnHeader: "Total",
|
|
DocumentPosition: review.DocumentPosition{Snapshot: cell},
|
|
}
|
|
for _, tc := range []struct {
|
|
verbosity int
|
|
want string
|
|
}{{0, result.DocumentCaretText}, {1, "Monday row header Total column header 42 Row 2, column 3."}} {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
options := presentation.DefaultOptions()
|
|
options.SpeechVerbosityLevel = tc.verbosity
|
|
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options)
|
|
if got := (<-queue.requests).Text; got != tc.want {
|
|
t.Fatalf("verbosity %d: %q, want %q", tc.verbosity, got, tc.want)
|
|
}
|
|
}
|
|
result.DocumentCaretCommand = review.TableDown
|
|
result.DocumentTableSuffix = "Row 2, column 3."
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
options := presentation.DefaultOptions()
|
|
options.SpeechVerbosityLevel = 1
|
|
reading := config.DefaultSettings().Reading
|
|
reading.ReadFullRowInDocumentTable = true
|
|
options.Reading = &reading
|
|
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options)
|
|
if got := (<-queue.requests).Text; got != "Monday row header Total column header 42, 18 Row 2, column 3." {
|
|
t.Fatalf("verbose full row = %q", got)
|
|
}
|
|
reading.OnlySpeakDisplayedText = true
|
|
queue = &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, options)
|
|
if got := (<-queue.requests).Text; got != "42, 18" {
|
|
t.Fatalf("displayed-only full row = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCaretPresentationUsesAncestorRoleSound(t *testing.T) {
|
|
leaf := atspi.NewSnapshot("/text", "", "static", "", "")
|
|
link := atspi.NewSnapshot("/link", "Example", "link", "", "")
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 2)}
|
|
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{
|
|
Moved: true, DocumentCaretCommand: review.CaretNextLine, DocumentCaretText: "Example",
|
|
DocumentPosition: review.DocumentPosition{Snapshot: leaf}, DocumentCaretContext: []atspi.Snapshot{leaf, link},
|
|
}, presentation.Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: "sound_only"})
|
|
if got := <-speech.spoken; got != "Example" {
|
|
t.Fatalf("sound-only speech = %q", got)
|
|
}
|
|
select {
|
|
case cue := <-queue.sounds:
|
|
if cue.Name != "link" {
|
|
t.Fatalf("cue = %+v", cue)
|
|
}
|
|
default:
|
|
t.Fatal("missing link sound")
|
|
}
|
|
}
|