245 lines
7.8 KiB
Go
245 lines
7.8 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
func TestNativeLinkedHeadings(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_LINKED_HEADINGS") != "1" {
|
|
t.Skip("requires private linked heading browser session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil {
|
|
t.Fatalf("fixture focus: %s, %v", output, err)
|
|
}
|
|
var focus atspi.ObjectID
|
|
var bounds atspi.Rectangle
|
|
for !focus.Valid() && ctx.Err() == nil {
|
|
tree, err := adapter.CaptureDesktop(ctx)
|
|
if err == nil {
|
|
focus = linkedHeadingFixtureFocus(tree)
|
|
if focus.Valid() {
|
|
ref, _ := tree.Reference(focus)
|
|
geometry, _ := tree.Geometry(ref)
|
|
if geometry.BoundsKnown {
|
|
bounds = geometry.Bounds
|
|
}
|
|
}
|
|
}
|
|
if !focus.Valid() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
if !focus.Valid() {
|
|
t.Fatal("linked heading fixture input absent from accessible desktop")
|
|
}
|
|
if !bounds.Valid() {
|
|
t.Fatal("linked heading fixture input has no screen bounds")
|
|
}
|
|
keys := []string{"key", "--clearmodifiers", "ctrl+l"}
|
|
if output, err := exec.CommandContext(ctx, "xdotool", keys...).CombinedOutput(); err != nil {
|
|
t.Fatalf("focus browser chrome: %s, %v", output, err)
|
|
}
|
|
pointX, pointY := bounds.X+bounds.Width/2, bounds.Y+bounds.Height/2
|
|
if output, err := exec.CommandContext(ctx, "xdotool", "mousemove", "--sync", fmt.Sprint(pointX), fmt.Sprint(pointY), "click", "1").CombinedOutput(); err != nil {
|
|
t.Fatalf("focus fixture input: %s, %v", output, err)
|
|
}
|
|
focused := false
|
|
deadline := time.NewTimer(5 * time.Second)
|
|
defer deadline.Stop()
|
|
for !focused {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
focused = event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == focus
|
|
case <-deadline.C:
|
|
t.Fatal("fixture input did not emit native focus")
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
t.Run("sound geometry after scrolling", func(t *testing.T) {
|
|
testLinkedHeadingSoundGeometry(t, ctx, adapter, focus)
|
|
})
|
|
for _, tc := range []struct {
|
|
level int
|
|
name, title string
|
|
moves int
|
|
}{
|
|
{2, "Inside heading link", "Cthulhu Linked Heading Inside", 1},
|
|
{3, "Outside heading link", "Cthulhu Linked Heading Outside", 1},
|
|
{3, "#1 Username Example date", "Cthulhu Linked Heading Post", 2},
|
|
} {
|
|
tree, err := adapter.CaptureDocument(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := review.NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.Move(ctx, review.DocumentHeading, tc.level, false)
|
|
for step := 1; step < tc.moves && err == nil; step++ {
|
|
move, err = cursor.Move(ctx, review.DocumentHeading, tc.level, false)
|
|
}
|
|
if err != nil || !move.Moved || move.Position.Snapshot.Name != tc.name || move.Position.LinkedHeading == nil {
|
|
t.Fatalf("heading level %d: %+v, %v", tc.level, move, err)
|
|
}
|
|
target, err := cursor.ActivationTarget()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link, err := tree.Resolve(target)
|
|
if err != nil || link.Role != "link" {
|
|
t.Fatalf("activation target = %+v, %v", link, err)
|
|
}
|
|
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
|
|
t.Fatalf("activate %s: %v", tc.name, err)
|
|
}
|
|
for {
|
|
output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", tc.title).Output()
|
|
if err == nil && strings.TrimSpace(string(output)) != "" {
|
|
break
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("activation did not open %s: %s", tc.title, output)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}
|
|
tree, err := adapter.CaptureDocument(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := review.NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.Move(ctx, review.DocumentHeading, 5, false)
|
|
if err != nil || !move.Moved || move.Position.LinkedHeading != nil {
|
|
t.Fatalf("plain-text heading with multiple links: %+v, %v", move, err)
|
|
}
|
|
if target, err := cursor.ActivationTarget(); !errors.Is(err, review.ErrNoCurrent) {
|
|
t.Fatalf("ambiguous activation target = %v, %v", target, err)
|
|
}
|
|
}
|
|
|
|
func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter *atspi.Adapter, focus atspi.ObjectID) {
|
|
t.Helper()
|
|
tree, err := adapter.CaptureDocument(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := review.NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cold, err := cursor.Move(ctx, review.DocumentHeading, 6, false)
|
|
if err != nil || !cold.Moved || cold.Position.LinkedHeading == nil {
|
|
t.Fatalf("cold offscreen heading = %+v, %v", cold, err)
|
|
}
|
|
if cold.Position.LinkedHeading.SoundGeometry.ObjectKnown {
|
|
t.Fatal("fixture heading must start offscreen without captured sound geometry")
|
|
}
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.SyncFocus(focus)
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
for _, step := range []struct {
|
|
target review.DocumentTarget
|
|
level int
|
|
backwards bool
|
|
name string
|
|
}{
|
|
{review.DocumentHeading, 6, false, "Offscreen first topic"},
|
|
{review.DocumentHeading, 6, false, "Offscreen second topic"},
|
|
{review.DocumentHeading, 6, true, "Offscreen first topic"},
|
|
{review.DocumentLink, 0, false, "Offscreen first topic"},
|
|
{review.DocumentLink, 0, false, "Offscreen second topic"},
|
|
{review.DocumentLink, 0, true, "Offscreen first topic"},
|
|
} {
|
|
if err := worker.MoveDocument(step.target, step.level, step.backwards); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case result := <-worker.Results():
|
|
if result.Err != nil || result.CaretErr != nil || result.DocumentSoundErr != nil ||
|
|
!result.Moved || result.DocumentPosition.Snapshot.Name != step.name {
|
|
t.Fatalf("scrolled heading result = %+v", result)
|
|
}
|
|
link := result.DocumentPosition.LinkedHeading
|
|
if step.target == review.DocumentLink {
|
|
link = &result.DocumentPosition.Snapshot
|
|
}
|
|
if link == nil || !link.SoundGeometry.ObjectKnown || !link.SoundGeometry.ReferenceKnown {
|
|
t.Fatalf("scrolled link lacks fresh geometry: %+v", link)
|
|
}
|
|
if !result.DocumentPosition.Snapshot.SoundGeometry.ObjectKnown {
|
|
t.Fatal("scrolled heading lacks fresh geometry")
|
|
}
|
|
cues := presentation.SoundCues(*link, presentation.Options{
|
|
EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true,
|
|
})
|
|
if len(cues) != 1 || cues[0].Pan >= -0.3 {
|
|
t.Fatalf("left-column link lost leftward sound: %+v", cues)
|
|
}
|
|
t.Logf("%s after scroll: bounds=%+v pan=%g", step.name, link.SoundGeometry.Object, cues[0].Pan)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
|
|
func linkedHeadingFixtureFocus(tree *atspi.AccessibleTree) atspi.ObjectID {
|
|
root, err := tree.Root()
|
|
if err != nil {
|
|
return atspi.ObjectID{}
|
|
}
|
|
pending := []atspi.ObjectReference{root}
|
|
for len(pending) != 0 {
|
|
current := pending[len(pending)-1]
|
|
pending = pending[:len(pending)-1]
|
|
snapshot, err := tree.Resolve(current)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if snapshot.Name == "Probe start" && snapshot.Role == "entry" {
|
|
return current.ID()
|
|
}
|
|
for child, ok, err := tree.FirstChild(current); err == nil && ok; child, ok, err = tree.NextSibling(child) {
|
|
pending = append(pending, child)
|
|
}
|
|
}
|
|
return atspi.ObjectID{}
|
|
}
|