197 lines
6.8 KiB
Go
197 lines
6.8 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"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
func TestProcessEventsPresentsFlatReviewThroughSpeechQueue(t *testing.T) {
|
|
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
|
snapshot := func(path, name, role string) atspi.Snapshot {
|
|
s := atspi.NewSnapshot(path, name, role, "", "")
|
|
s.BusName = ":1.42"
|
|
return s
|
|
}
|
|
root := snapshot("/window", "Window", "frame")
|
|
field := snapshot("/field", "Review field", "text")
|
|
geometry := atspi.Geometry{Bounds: atspi.Rectangle{Width: 200, Height: 50}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
|
tree, err := atspi.NewAccessibleTree(id("/window"), []atspi.TreeNode{
|
|
{Snapshot: root, Geometry: geometry, Children: []atspi.ObjectID{id("/field")}},
|
|
{Snapshot: field, Geometry: geometry, Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: 6, Text: "a 猫!oo", Bounds: geometry.Bounds}}}},
|
|
})
|
|
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())
|
|
defer cancel()
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
|
|
completed := make(chan error, 1)
|
|
go func() {
|
|
completed <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0)
|
|
}()
|
|
expect := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech=%q want=%q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("speech missing")
|
|
}
|
|
}
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, field)
|
|
expect("Review field, text")
|
|
worker.ReadFlat(review.Line, 0)
|
|
expect("a 猫!oo")
|
|
worker.ReadFlat(review.Character, 1)
|
|
expect("space")
|
|
worker.ReadFlat(review.Character, 1)
|
|
expect("猫")
|
|
worker.ReadFlat(review.Word, 0)
|
|
expect("猫!oo")
|
|
worker.ReadFlat(review.Character, 1)
|
|
expect("!")
|
|
worker.ReadFlat(review.Character, 1)
|
|
expect("o")
|
|
worker.ReadFlat(review.Character, 1)
|
|
expect("o")
|
|
cancel()
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestFlatReviewResultUsesNativeFocusAndRejectsOldPage(t *testing.T) {
|
|
worker, err := review.NewWorker(&fakeAdapter{}, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
document := atspi.NewObjectID(":1.42", "/document")
|
|
worker.SyncFocus(document)
|
|
result := review.Result{FlatUnit: review.Line, FlatFocus: document}
|
|
// A browse caret can be on a child while native focus remains here.
|
|
if !flatReviewResultCurrent(worker, result) {
|
|
t.Fatal("flat result from native document focus was discarded")
|
|
}
|
|
worker.ResetDocument(document)
|
|
if flatReviewResultCurrent(worker, result) {
|
|
t.Fatal("flat result from the previous page was accepted")
|
|
}
|
|
result.DocumentEpoch = 1
|
|
worker.SyncFocus(atspi.NewObjectID(":1.42", "/address"))
|
|
if flatReviewResultCurrent(worker, result) {
|
|
t.Fatal("flat result after focus left the document was accepted")
|
|
}
|
|
}
|
|
|
|
func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
for _, result := range []review.Result{
|
|
{FlatUnit: review.Line, FlatStep: 1},
|
|
{FlatUnit: review.WordAbove},
|
|
{FlatUnit: review.WordBelow},
|
|
} {
|
|
presentFlatReviewResult(logger, queue, result)
|
|
select {
|
|
case <-queue.spoken:
|
|
t.Fatalf("boundary spoke for unit %d", result.FlatUnit)
|
|
default:
|
|
}
|
|
}
|
|
presentFlatReviewResult(logger, queue, review.Result{FlatUnit: review.Line, Err: review.ErrLimitedReview})
|
|
if got := <-queue.spoken; got != "This window exceeds the current flat review text limit." {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestFlatReviewWindowCommands(t *testing.T) {
|
|
if unit, step := flatReviewCommand(input.ReviewWindowStart); unit != review.WindowStart || step != 0 {
|
|
t.Fatalf("start = %d, %d", unit, step)
|
|
}
|
|
if unit, step := flatReviewCommand(input.ReviewWindowEnd); unit != review.WindowEnd || step != 0 {
|
|
t.Fatalf("end = %d, %d", unit, step)
|
|
}
|
|
if unit, step := flatReviewCommand(input.ReviewWordAbove); unit != review.WordAbove || step != 0 {
|
|
t.Fatalf("above = %d, %d", unit, step)
|
|
}
|
|
if unit, step := flatReviewCommand(input.ReviewWordBelow); unit != review.WordBelow || step != 0 {
|
|
t.Fatalf("below = %d, %d", unit, step)
|
|
}
|
|
if unit, step := flatReviewCommand(input.ReviewLineEnd); unit != review.LineEnd || step != 0 {
|
|
t.Fatalf("line end = %d, %d", unit, step)
|
|
}
|
|
}
|
|
|
|
func TestFlatReviewBlankAndWhitespaceSpeech(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
for _, tc := range []struct {
|
|
unit review.FlatUnit
|
|
text, want string
|
|
}{
|
|
{review.Line, "\n", "blank"},
|
|
{review.Character, "\n", "blank"},
|
|
{review.WindowEnd, "\n", "blank"},
|
|
{review.Line, " ", "white space"},
|
|
{review.Word, "\t", "white space"},
|
|
{review.Character, " ", "space"},
|
|
{review.LineEnd, "\n", "blank"},
|
|
} {
|
|
presentFlatReviewResult(logger, queue, review.Result{FlatUnit: tc.unit, Moved: true, FlatPosition: review.FlatPosition{Text: tc.text}})
|
|
if got := <-queue.spoken; got != tc.want {
|
|
t.Fatalf("unit=%v text=%q: got=%q want=%q", tc.unit, tc.text, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFlatReviewCharacterVoice(t *testing.T) {
|
|
for _, unit := range []review.FlatUnit{review.Character, review.LineEnd, review.Word, review.Line} {
|
|
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
|
presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, review.Result{Moved: true, FlatUnit: unit})
|
|
request := <-queue.requests
|
|
want := unit == review.Character || unit == review.LineEnd
|
|
if request.Character != want {
|
|
t.Fatalf("unit %v: character = %v", unit, request.Character)
|
|
}
|
|
}
|
|
}
|