// 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 . 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") } }