Files
cthulhu/internal/app/flat_review_test.go

270 lines
9.6 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/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestFlatReviewPartialDiagnosticsPreserveSpellingWithoutExtraMessages(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
braille := &flashRecorder{}
queue := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
output := outputQueue{speech: queue, braille: braille, live: live}
result := review.Result{FlatUnit: review.Word, FlatSpeech: review.FlatSpell, FlatWarning: true,
FlatCompleteness: review.FlatCompleteness{OmittedObjects: 1}, FlatPosition: review.FlatPosition{Text: "cat"}}
presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result)
content := <-queue.requests
if len(content.Spelling) != 3 || content.Text != "cat" || len(queue.requests) != 0 {
t.Fatalf("content metadata or extra message: %+v, queued=%d", content, len(queue.requests))
}
if len(braille.messages) != 0 {
t.Fatalf("unexpected Braille warning: %v", braille.messages)
}
result.FlatUnit, result.FlatStep, result.Moved = review.Line, -1, false
presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result)
if boundary := <-queue.requests; boundary.Text != "Top of screen." {
t.Fatalf("partial top boundary: %+v", boundary)
}
result.FlatStep = 1
presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result)
if boundary := <-queue.requests; boundary.Text != "Bottom of screen." || len(queue.requests) != 0 {
t.Fatalf("partial bottom boundary or extra message: %+v", boundary)
}
}
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.Line, -1)
expect("Top of screen.")
worker.ReadFlat(review.Line, 0)
expect("a 猫!oo")
worker.ReadFlat(review.Line, 1)
expect("Bottom of screen.")
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 TestFlatReviewLineBoundaryFeedback(t *testing.T) {
for _, tc := range []struct {
name string
step int
want string
}{
{"top", -1, "Top of screen."},
{"bottom", 1, "Bottom of screen."},
} {
t.Run(tc.name, func(t *testing.T) {
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
result := review.Result{
FlatUnit: review.Line, FlatStep: tc.step, FlatSpeech: review.FlatSpell,
FlatPosition: review.FlatPosition{Text: " Edge line"},
}
for range 2 {
presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, result)
select {
case request := <-queue.requests:
if request.Text != tc.want || request.Cause != "flat-review" {
t.Fatalf("boundary request = %+v", request)
}
if request.Line != nil || request.Character || len(request.Spelling) != 0 {
t.Fatalf("boundary retained content speech metadata: %+v", request)
}
default:
t.Fatal("boundary was silent")
}
}
})
}
}
func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 2)}
logger := slog.New(slog.DiscardHandler)
for _, result := range []review.Result{
{FlatUnit: review.Word, FlatStep: -1},
{FlatUnit: review.Word, FlatStep: 1},
{FlatUnit: review.Character, FlatStep: -1},
{FlatUnit: review.Character, 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)
}
}
}