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