Files
cthulhu/internal/app/object_review_events_test.go

230 lines
6.2 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/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
speech := &fakeSpeechQueue{
spoken: make(chan string, 2),
interrupt: make(chan struct{}, 1),
}
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
reviewRequests := make(chan input.Command, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan error, 1)
options := presentation.DefaultOptions()
options.EnableSound = true
options.PlaySoundForRole = true
options.RoleSoundPresentation = "sound_only"
go func() {
completed <- processEventsWithReview(
ctx,
slog.New(slog.DiscardHandler),
speech,
adapter,
reviewRequests,
0,
options,
)
}()
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.NewSnapshot(
"/accessible/example", "Example", "button", "Runs the example", "",
))
select {
case text := <-speech.spoken:
if text != "Example, Runs the example" {
t.Fatalf("focus speech = %q", text)
}
case <-time.After(time.Second):
t.Fatal("focus was not presented")
}
reviewRequests <- input.WhereAmI
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("where am I did not interrupt stale speech")
}
select {
case text := <-speech.spoken:
if text != "Example, button, Runs the example" {
t.Fatalf("where am I speech = %q", text)
}
case <-time.After(time.Second):
t.Fatal("where am I was not presented")
}
cancel()
select {
case err := <-completed:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("event loop did not stop")
}
}
func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
reviewRequests := make(chan input.Command, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan error, 1)
go func() {
completed <- processEventsWithReview(
ctx,
slog.New(slog.DiscardHandler),
speech,
adapter,
reviewRequests,
0,
)
}()
reviewRequests <- input.WhereAmI
select {
case text := <-speech.spoken:
if text != "Could not find current location." {
t.Fatalf("where am I without focus = %q", text)
}
case <-time.After(time.Second):
t.Fatal("missing-focus message was not presented")
}
cancel()
select {
case <-completed:
case <-time.After(time.Second):
t.Fatal("event loop did not stop")
}
}
func TestProcessEventsSynchronizesAndPresentsObjectReview(t *testing.T) {
rootID := atspi.NewObjectID(":1.42", "/root")
firstID := atspi.NewObjectID(":1.42", "/first")
secondID := atspi.NewObjectID(":1.42", "/second")
root := atspi.NewSnapshot("/root", "Window", "frame", "", "")
root.BusName = ":1.42"
first := atspi.NewSnapshot("/first", "First", "push button", "", "")
first.BusName = ":1.42"
second := atspi.NewSnapshot("/second", "Second", "check box", "", "")
second.BusName = ":1.42"
second.Checked = true
tree, err := atspi.NewAccessibleTree(rootID, []atspi.TreeNode{
{Snapshot: root, Children: []atspi.ObjectID{firstID, secondID}},
{Snapshot: first},
{Snapshot: second},
})
if err != nil {
t.Fatal(err)
}
adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree}
objectReview, err := review.NewWorker(adapter, time.Second)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := objectReview.Start(ctx); err != nil {
t.Fatal(err)
}
speech := &fakeSpeechQueue{spoken: make(chan string, 3), interrupt: make(chan struct{}, 2)}
completed := make(chan error, 1)
go func() {
completed <- processEventsWithObjectReview(
ctx,
slog.New(slog.DiscardHandler),
speech,
adapter,
nil,
objectReview,
0,
)
}()
adapter.events <- atspi.NewEvent(atspi.FocusEvent, first)
select {
case text := <-speech.spoken:
if text != "First, push button" {
t.Fatalf("focus speech = %q", text)
}
case <-time.After(time.Second):
t.Fatal("focus was not presented")
}
if err := objectReview.Move(review.NextSibling); err != nil {
t.Fatal(err)
}
select {
case text := <-speech.spoken:
if text != "Second, check box, checked" {
t.Fatalf("object review speech = %q", text)
}
case <-time.After(time.Second):
t.Fatal("object review movement was not presented")
}
if err := objectReview.Move(review.FirstChild); err != nil {
t.Fatal(err)
}
select {
case text := <-speech.spoken:
if text != "No children." {
t.Fatalf("object review boundary = %q", text)
}
case <-time.After(time.Second):
t.Fatal("object review boundary was not presented")
}
cancel()
select {
case err := <-completed:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("event loop did not stop")
}
if err := objectReview.Stop(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestObjectReviewBoundaryMessagesMatchPython(t *testing.T) {
tests := map[review.Direction]string{
review.Parent: "No parent.",
review.FirstChild: "No children.",
review.PreviousSibling: "No previous.",
review.NextSibling: "No next.",
}
for direction, expected := range tests {
if got := objectReviewBoundaryMessage(direction); got != expected {
t.Fatalf("direction %d = %q, want %q", direction, got, expected)
}
}
}