314 lines
11 KiB
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")
|
|
}
|
|
}
|