194 lines
6.3 KiB
Go
194 lines
6.3 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 review
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestDirectionNames(t *testing.T) {
|
|
tests := map[Direction]string{
|
|
Parent: "parent",
|
|
FirstChild: "first-child",
|
|
PreviousSibling: "previous-sibling",
|
|
NextSibling: "next-sibling",
|
|
Direction(99): "direction(99)",
|
|
}
|
|
for direction, expected := range tests {
|
|
if got := direction.String(); got != expected {
|
|
t.Fatalf("direction %d = %q, want %q", direction, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func objectID(path string) atspi.ObjectID {
|
|
return atspi.NewObjectID(":1.42", path)
|
|
}
|
|
|
|
func node(path, name string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
snapshot := atspi.NewSnapshot(path, name, "push button", "", "")
|
|
snapshot.BusName = ":1.42"
|
|
return atspi.TreeNode{Snapshot: snapshot, Children: children}
|
|
}
|
|
|
|
func newTree(t *testing.T) *atspi.AccessibleTree {
|
|
t.Helper()
|
|
tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{
|
|
node("/root", "Window", objectID("/first"), objectID("/second")),
|
|
node("/first", "First"),
|
|
node("/second", "Second", objectID("/grandchild")),
|
|
node("/grandchild", "Grandchild"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tree
|
|
}
|
|
|
|
func TestCursorFollowsChangedFocusAndPreservesIndependentNavigation(t *testing.T) {
|
|
cursor, err := NewCursor(newTree(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, err := cursor.SyncFocus(objectID("/first"))
|
|
if err != nil || position.Current.Name != "First" || position.HasPrevious {
|
|
t.Fatalf("initial position = %#v, error = %v", position, err)
|
|
}
|
|
|
|
position, moved, err := cursor.Move(NextSibling)
|
|
if err != nil || !moved || position.Current.Name != "Second" || !position.HasPrevious || position.Previous.Name != "First" {
|
|
t.Fatalf("next position = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
position, err = cursor.SyncFocus(objectID("/first"))
|
|
if err != nil || position.Current.Name != "Second" {
|
|
t.Fatalf("repeated focus pulled cursor back: position = %#v, error = %v", position, err)
|
|
}
|
|
|
|
position, err = cursor.SyncFocus(objectID("/grandchild"))
|
|
if err != nil || position.Current.Name != "Grandchild" || !position.HasPrevious || position.Previous.Name != "Second" {
|
|
t.Fatalf("changed focus position = %#v, error = %v", position, err)
|
|
}
|
|
}
|
|
|
|
func TestCursorTraversesRelationshipsAndPreservesBoundaries(t *testing.T) {
|
|
cursor, _ := NewCursor(newTree(t))
|
|
if _, err := cursor.SyncFocus(objectID("/second")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
position, moved, err := cursor.Move(FirstChild)
|
|
if err != nil || !moved || position.Current.Name != "Grandchild" {
|
|
t.Fatalf("child position = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
position, moved, err = cursor.Move(FirstChild)
|
|
if err != nil || moved || position.Current.Name != "Grandchild" {
|
|
t.Fatalf("child boundary = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
position, moved, err = cursor.Move(Parent)
|
|
if err != nil || !moved || position.Current.Name != "Second" {
|
|
t.Fatalf("parent position = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
position, moved, err = cursor.Move(PreviousSibling)
|
|
if err != nil || !moved || position.Current.Name != "First" {
|
|
t.Fatalf("previous position = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
position, moved, err = cursor.Move(PreviousSibling)
|
|
if err != nil || moved || position.Current.Name != "First" {
|
|
t.Fatalf("previous boundary = %#v, moved = %v, error = %v", position, moved, err)
|
|
}
|
|
}
|
|
|
|
func TestCursorRepairsStaleCurrentFromUnchangedFocus(t *testing.T) {
|
|
tree := newTree(t)
|
|
cursor, _ := NewCursor(tree)
|
|
if _, err := cursor.SyncFocus(objectID("/first")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tree.Invalidate(objectID("/first"))
|
|
if _, err := cursor.Current(); !errors.Is(err, atspi.ErrStaleObject) {
|
|
t.Fatalf("current error = %v, want stale object", err)
|
|
}
|
|
if err := tree.Replace(objectID("/root"), []atspi.TreeNode{
|
|
node("/root", "Window", objectID("/first")),
|
|
node("/first", "Reintroduced"),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, err := cursor.SyncFocus(objectID("/first"))
|
|
if err != nil || position.Current.Name != "Reintroduced" {
|
|
t.Fatalf("repaired position = %#v, error = %v", position, err)
|
|
}
|
|
}
|
|
|
|
func TestCursorErrorsDoNotMoveAndResetClearsState(t *testing.T) {
|
|
cursor, _ := NewCursor(newTree(t))
|
|
if _, _, err := cursor.Move(Parent); !errors.Is(err, ErrNoCurrent) {
|
|
t.Fatalf("empty move error = %v", err)
|
|
}
|
|
if _, err := cursor.SyncFocus(atspi.ObjectID{}); err == nil {
|
|
t.Fatal("invalid focus was accepted")
|
|
}
|
|
if _, err := cursor.SyncFocus(objectID("/first")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := cursor.Move(Direction(99)); !errors.Is(err, ErrInvalidDirection) {
|
|
t.Fatalf("invalid direction error = %v", err)
|
|
}
|
|
position, err := cursor.Current()
|
|
if err != nil || position.Current.Name != "First" {
|
|
t.Fatalf("position after invalid move = %#v, error = %v", position, err)
|
|
}
|
|
cursor.Reset()
|
|
if _, err := cursor.Current(); !errors.Is(err, ErrNoCurrent) {
|
|
t.Fatalf("reset current error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCursorSerializesFocusAndNavigation(t *testing.T) {
|
|
cursor, _ := NewCursor(newTree(t))
|
|
if _, err := cursor.SyncFocus(objectID("/first")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wait sync.WaitGroup
|
|
for index := 0; index < 20; index++ {
|
|
wait.Add(2)
|
|
go func() {
|
|
defer wait.Done()
|
|
_, _ = cursor.SyncFocus(objectID("/first"))
|
|
}()
|
|
go func() {
|
|
defer wait.Done()
|
|
_, _, _ = cursor.Move(NextSibling)
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
if _, err := cursor.Current(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestNewCursorRejectsNilTree(t *testing.T) {
|
|
if _, err := NewCursor(nil); err == nil {
|
|
t.Fatal("nil tree was accepted")
|
|
}
|
|
}
|