Files
cthulhu/internal/app/document_line_test.go

314 lines
11 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"
"log/slog"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"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"
)
type documentLineCaptureCall struct {
focus atspi.ObjectID
result chan *atspi.AccessibleTree
}
type documentLineCapturer struct {
calls chan documentLineCaptureCall
}
func (capturer *documentLineCapturer) capture(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
call := documentLineCaptureCall{focus: focus, result: make(chan *atspi.AccessibleTree, 1)}
capturer.calls <- call
select {
case tree := <-call.result:
return tree, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (capturer *documentLineCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, atspi.ObjectID{})
}
func (capturer *documentLineCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
func (capturer *documentLineCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
func (capturer *documentLineCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
type documentLineKeys struct{}
func (keys *documentLineKeys) SetBrowseNavigation(bool) {}
func documentLineTree(t *testing.T, staticText string) *atspi.AccessibleTree {
t.Helper()
baseNode := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, path, role, "", "")
snapshot.BusName = ":1.42"
return atspi.TreeNode{Snapshot: snapshot, Children: children}
}
lineNode := func(path, role, text string, bounds atspi.Rectangle, caret bool) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, path, role, "", text)
snapshot.BusName = ":1.42"
geometry := atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
visible := atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{
Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds,
}}}
if caret {
visible.CaretKnown, visible.CaretOffset = true, 1
}
return atspi.TreeNode{Snapshot: snapshot, Geometry: geometry, Text: visible}
}
tree, err := atspi.NewAccessibleTree(atspi.NewObjectID(":1.42", "/root"), []atspi.TreeNode{
baseNode("/root", "frame", atspi.NewObjectID(":1.42", "/document")),
baseNode("/document", "document web", atspi.NewObjectID(":1.42", "/static"), atspi.NewObjectID(":1.42", "/link")),
lineNode("/static", "static", staticText, atspi.Rectangle{X: 0, Y: 100, Width: 60, Height: 20}, false),
lineNode("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false),
})
if err != nil {
t.Fatal(err)
}
return tree
}
func TestProcessEventsSpeaksNewestDocumentCaretLine(t *testing.T) {
capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}
worker, err := review.NewWorker(capturer, 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 func() {
stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
if err := worker.Stop(stopContext); err != nil {
t.Fatal(err)
}
}()
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
browser := newBrowserModeController(tracker, &documentLineKeys{}, nil)
browser.mode = review.FocusMode
browser.ready = true
adapter := &fakeAdapter{events: make(chan atspi.Event)}
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil)
}()
static := atspi.NewSnapshot("/static", "/static", "static", "", "First ")
static.BusName = ":1.42"
staticEvent := atspi.NewEvent(atspi.CaretEvent, static)
staticEvent.Detail1 = 1
adapter.events <- staticEvent
firstCapture := <-capturer.calls
link := atspi.NewSnapshot("/link", "/link", "link", "", "the link")
link.BusName = ":1.42"
linkEvent := atspi.NewEvent(atspi.CaretEvent, link)
linkEvent.Detail1 = 1
adapter.events <- linkEvent
// Native capture observes the current document when it completes, not the
// event which originally caused the capture.
firstCapture.result <- documentLineTree(t, "Second ")
select {
case got := <-speech.spoken:
if got != "Second, the link, link" {
t.Fatalf("document line speech = %q", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for document line speech")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for event loop to stop")
}
select {
case got := <-speech.spoken:
t.Fatalf("unexpected stale document line speech = %q", got)
default:
}
}
func TestProcessEventsUsesCapturedLineInBrowseModeWithoutReviewWorker(t *testing.T) {
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
browser := newBrowserModeController(tracker, &documentLineKeys{}, nil)
browser.mode = review.BrowseMode
browser.ready = true
adapter := &fakeAdapter{events: make(chan atspi.Event)}
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
_ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
retainedFocus := atspi.NewSnapshot("/old-heading", "Retained heading", "heading", "", "")
retainedFocus.BusName = ":1.42"
retainedFocus.Focusable = true
adapter.events <- atspi.NewEvent(atspi.FocusEvent, retainedFocus)
snapshot := atspi.NewSnapshot("/paragraph", "", "paragraph", "", "Current browser line")
snapshot.BusName = ":1.42"
event := atspi.NewEvent(atspi.CaretEvent, snapshot)
event.Detail1 = 4
adapter.events <- event
select {
case got := <-speech.spoken:
if got != "Current browser line" {
t.Fatalf("browse line speech = %q", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for browse line speech")
}
if !browser.ready || browser.mode != review.BrowseMode {
t.Fatalf("retained DOM focus changed browse state: ready=%v mode=%v", browser.ready, browser.mode)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for event loop to stop")
}
}
func TestBlankBrowseCaretDoesNotCaptureWholeDocument(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 1)}
worker, err := review.NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
browser := newBrowserModeController(tracker, &documentLineKeys{}, nil)
browser.mode, browser.ready = review.BrowseMode, true
adapter := &fakeAdapter{events: make(chan atspi.Event)}
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
done := make(chan error, 1)
go func() {
done <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil)
}()
blank := atspi.NewSnapshot("/link", "", "link", "", "")
blank.BusName = ":1.42"
adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank)
select {
case <-capturer.calls:
t.Fatal("blank browse caret started full document text capture")
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 TestBrowserCaretUpdatesStructuralPositionAndSounds(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}
worker, err := review.NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
browser := newBrowserModeController(tracker, &documentLineKeys{}, nil)
browser.ready, browser.mode = true, review.BrowseMode
adapter := &fakeAdapter{events: make(chan atspi.Event)}
queue := &modeSoundQueue{speech: &fakeSpeechQueue{spoken: make(chan string, 4)}, sounds: make(chan sound.Cue, 4)}
options := presentation.DefaultOptions()
options.EnableSound, options.PlaySoundForRole = true, true
options.RoleSoundPresentation = "sound_only"
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0, nil, browser, nil, nil, options)
}()
defer func() { cancel(); <-done }()
snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/link", Role: "link", Text: "the link", Visited: true}
adapter.events <- atspi.NewEvent(atspi.CaretEvent, snapshot)
select {
case text := <-queue.speech.spoken:
if text != "the link" {
t.Fatalf("caret speech = %q", text)
}
case <-time.After(time.Second):
t.Fatal("missing caret speech")
}
select {
case cue := <-queue.sounds:
if cue.Name != "link_visited" {
t.Fatalf("caret cue = %#v", cue)
}
case <-time.After(time.Second):
t.Fatal("missing caret sound")
}
worker.MoveDocument(review.DocumentHeading, 0, false)
select {
case call := <-capturer.calls:
if call.focus != snapshot.ID() {
t.Fatalf("structural navigation anchored at %v, want caret %v", call.focus, snapshot.ID())
}
case <-time.After(time.Second):
t.Fatal("structural navigation did not follow the caret")
}
}