Files
cthulhu/internal/app/linked_headings_native_test.go
2026-10-14 20:39:01 -04:00

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{}
}