// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "errors" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type refreshingActivator struct { *readAllCapturer target atspi.ObjectID staleOnce bool } func TestActivationDoesNotRecaptureNonClickableText(t *testing.T) { cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): "Ordinary paragraph"}) capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0) if !errors.Is(result.Err, ErrNoDocumentAction) || len(capturer.calls) != 0 { t.Fatalf("plain text triggered a capture: %+v", result) } } func TestActivationRejectsReplacedDocumentGeneration(t *testing.T) { tree := activationTree(t, true) cursor, _ := NewDocumentCursor(tree) cursor.SyncFocus(context.Background(), webID("/focused")) cursor.places[0].object, _ = tree.Reference(webID("/selected")) tree.Invalidate(webID("/doc")) nodes := []atspi.TreeNode{ {Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/focused"), webID("/selected")}}, {Snapshot: atspi.Snapshot{BusName: webID("/focused").BusName, Path: "/focused", Role: "link"}}, {Snapshot: atspi.Snapshot{BusName: webID("/selected").BusName, Path: "/selected", Role: "link"}}, } if err := tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } activator := &refreshingActivator{readAllCapturer: &readAllCapturer{tree: tree}} worker, _ := NewWorker(activator, time.Second) worker.SyncFocus(webID("/focused")) result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0) if result.Err == nil || activator.target.Valid() { t.Fatalf("activation crossed a document generation: %+v, %v", result, activator.target) } } func TestBrowseNamedCustomClickableAndActivateAncestor(t *testing.T) { reader := caretTextReader{webID("/start"): "Before action", webID("/label"): "Visible label", webID("/after"): "After action"} for _, explicit := range []bool{false, true} { nodes := []atspi.TreeNode{ {Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/start"), webID("/custom"), webID("/after")}}, {Snapshot: atspi.Snapshot{BusName: webID("/start").BusName, Path: "/start", Role: "static", CharacterCount: 13}}, {Snapshot: atspi.Snapshot{BusName: webID("/custom").BusName, Path: "/custom", Role: "section", Name: "Perform action", SupportsClick: true, ExplicitName: explicit}, Children: []atspi.ObjectID{webID("/label")}}, {Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", CharacterCount: 13}}, {Snapshot: atspi.Snapshot{BusName: webID("/after").BusName, Path: "/after", Role: "static", CharacterCount: 12}}, } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) cursor.SyncFocus(context.Background(), webID("/start")) move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) want := webID("/label") if explicit { want = webID("/custom") } if err != nil || !move.Moved || move.Position.Object.ID() != want { t.Fatalf("custom clickable stop: %+v, %v", move, err) } target, err := cursor.ActivationTarget() if err != nil || target.ID() != webID("/custom") { t.Fatalf("activation target = %v, %v", target, err) } move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine) if err != nil || move.Text != "After action" { t.Fatalf("clickable trapped navigation: %+v, %v", move, err) } } } func (activator *refreshingActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, _ atspi.ObjectID) error { if activator.staleOnce { activator.staleOnce = false return atspi.ErrStaleObject } activator.target = target.ID() return nil } func activationTree(t *testing.T, includeSelected bool) *atspi.AccessibleTree { t.Helper() root := atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/focused")}} nodes := []atspi.TreeNode{root, {Snapshot: atspi.Snapshot{BusName: webID("/focused").BusName, Path: "/focused", Role: "link"}}} if includeSelected { nodes[0].Children = append(nodes[0].Children, webID("/selected")) nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/selected").BusName, Path: "/selected", Role: "link"}}) } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } return tree } func TestActivationRecoversUncapturedAndStaleCursors(t *testing.T) { for _, kind := range []string{"uncaptured", "stale", "removed", "native-stale"} { t.Run(kind, func(t *testing.T) { fresh := activationTree(t, kind != "removed") activator := &refreshingActivator{readAllCapturer: &readAllCapturer{tree: fresh}, staleOnce: kind == "native-stale"} worker, _ := NewWorker(activator, time.Second) worker.SyncFocus(webID("/focused")) var cursor *DocumentCursor if kind != "uncaptured" { old := activationTree(t, true) cursor, _ = NewDocumentCursor(old) cursor.SyncFocus(context.Background(), webID("/focused")) cursor.places[0].object, _ = old.Reference(webID("/selected")) if kind != "native-stale" { old.Invalidate(webID("/selected")) } } result, updated := worker.activateDocumentCaret(context.Background(), cursor, 0) if kind == "removed" { if result.Err == nil || activator.target.Valid() { t.Fatal("activated a different link after selected link disappeared") } } else { want := webID("/selected") if kind == "uncaptured" { want = webID("/focused") } if result.Err != nil || activator.target != want { t.Fatalf("activation = %v, %v; want %v", activator.target, result.Err, want) } if current, err := updated.ActivationTarget(); err != nil || current.ID() != want { t.Fatalf("cursor lost after activation: %v, %v", current, err) } } }) } }