// 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 . 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") } }