// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "errors" "fmt" "math" "reflect" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/presentation" ) type soundGeometryNavigator struct { *fakeDocumentNavigator geometry [2]atspi.SoundGeometry err error reads int t *testing.T link atspi.ObjectID offset int } func (navigator *soundGeometryNavigator) SetDocumentCaretPosition(ctx context.Context, object atspi.ObjectReference, focus atspi.ObjectID, offset int) error { navigator.offset = offset return navigator.SetDocumentPosition(ctx, object, focus) } func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) { navigator.reads++ select { case placed := <-navigator.calls: wantLink := navigator.link if !wantLink.Valid() { wantLink = webID("/topic_link") } if placed.position != object || link.ID() != wantLink { navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link") } default: navigator.t.Fatal("geometry read before scrolling the target") } return navigator.geometry, navigator.err } func TestCaretLinkSoundRefreshKeepsLeafAndOffsets(t *testing.T) { old := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true, Object: atspi.Rectangle{X: 900, Width: 80, Height: 20}, Reference: atspi.Rectangle{Width: 1000, Height: 600}} for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd} { for _, state := range []string{"left", "center", "right", "disabled", "failure", "stale"} { t.Run(fmt.Sprintf("%v/%s", command, state), func(t *testing.T) { nodes := []atspi.TreeNode{ {Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/link")}}, {Snapshot: atspi.Snapshot{BusName: webID("/link").BusName, Path: "/link", Role: "link", Name: "Wrapped link", Visited: true, SoundGeometry: old}, Children: []atspi.ObjectID{webID("/label")}}, {Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", Name: "Wrapped link", CharacterCount: 12, SoundGeometry: old}}, } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) position, err := cursor.SyncFocus(context.Background(), webID("/label")) if err != nil { t.Fatal(err) } owner, _ := tree.DocumentContext(position.Object) position.Offset = 8 fresh := old fresh.Object.X = map[string]int32{"left": 0, "center": 460, "right": 920}[state] navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t, link: webID("/link")} want := fresh switch state { case "disabled": want = atspi.SoundGeometry{} case "failure": want = atspi.SoundGeometry{} navigator.err = context.DeadlineExceeded case "stale": want = atspi.SoundGeometry{} tree.Invalidate(webID("/link")) default: navigator.geometry = [2]atspi.SoundGeometry{fresh, fresh} } worker, _ := NewWorker(navigator, time.Second) worker.SyncFocus(webID("/doc")) result := worker.placeDocumentCaret(context.Background(), cursor, Result{ Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"), DocumentPosition: position, DocumentCaretCommand: command, DocumentCaretContext: owner.Ancestors, }) if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != position.Object || result.DocumentPosition.Offset != 8 || navigator.offset != 8 || result.DocumentPosition.Snapshot.Role != "static" { t.Fatalf("sound refresh changed caret ownership: %+v", result) } link := result.DocumentCaretContext[1] if link.SoundGeometry != want || !link.Visited || owner.Ancestors[1].SoundGeometry != old { t.Fatalf("ancestor geometry/semantics: %+v", link) } if state == "failure" && !errors.Is(result.DocumentSoundErr, context.DeadlineExceeded) || state == "stale" && result.DocumentSoundErr == nil { t.Fatalf("missing refresh failure: %v", result.DocumentSoundErr) } options := presentation.Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: state != "disabled"} cues := presentation.SoundCues(link, options) if len(cues) != 1 || cues[0].Name != "link_visited" { t.Fatalf("link cue = %+v", cues) } wantPan := map[string]float64{"left": -1, "right": 1}[state] if math.Abs(cues[0].Pan-wantPan) > 0.001 { t.Fatalf("pan = %g, want %g", cues[0].Pan, wantPan) } }) } } } func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) { oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}} freshGeometry := atspi.SoundGeometry{ ObjectKnown: true, ReferenceKnown: true, Object: atspi.Rectangle{X: 100, Y: 300, Width: 50, Height: 15}, Reference: atspi.Rectangle{Width: 1000, Height: 600}, } for _, name := range []string{"fresh", "unavailable", "refresh failed", "placement failed"} { t.Run(name, func(t *testing.T) { node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, "Topic", role, "", "") snapshot.BusName = webID(path).BusName snapshot.SoundGeometry = oldGeometry return atspi.TreeNode{Snapshot: snapshot, Children: children} } tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ node("/doc", "document web", webID("/heading")), node("/heading", "heading", webID("/topic_link")), node("/topic_link", "link"), }) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) document, _ := tree.Root() heading, _ := tree.Reference(webID("/heading")) linkRef, _ := tree.Reference(webID("/topic_link")) snapshot, _ := tree.Resolve(heading) link, _ := tree.Resolve(linkRef) navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t} want := atspi.SoundGeometry{} switch name { case "fresh": navigator.geometry = [2]atspi.SoundGeometry{freshGeometry, freshGeometry} want = freshGeometry case "refresh failed": navigator.err = context.DeadlineExceeded case "placement failed": navigator.placementErr <- atspi.ErrStaleObject want = oldGeometry } worker, _ := NewWorker(navigator, time.Second) worker.SyncFocus(webID("/doc")) result := worker.placeDocument(context.Background(), cursor, Result{ Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"), DocumentPosition: DocumentPosition{Document: document, Object: heading, Snapshot: snapshot, LinkedHeading: &link}, }) if result.Err != nil || !errors.Is(result.DocumentSoundErr, navigator.err) { t.Fatalf("sound refresh affected navigation: %+v", result) } if result.DocumentPosition.Snapshot.SoundGeometry != want || result.DocumentPosition.LinkedHeading.SoundGeometry != want { t.Fatalf("geometry after %s = %+v", name, result.DocumentPosition) } if link.SoundGeometry != oldGeometry { t.Fatal("refresh mutated the previously captured link snapshot") } cached, _ := tree.Resolve(heading) if !reflect.DeepEqual(cached, snapshot) { t.Fatal("refresh changed cached document semantics or geometry") } if name == "placement failed" && navigator.reads != 0 { t.Fatal("failed caret placement still queried sound geometry") } }) } }