Files
cthulhu/internal/app/document_review_test.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")
}
}