230 lines
6.2 KiB
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)
|
|
}
|
|
}
|
|
}
|