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