187 lines
7.7 KiB
Go
187 lines
7.7 KiB
Go
// 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")
|
|
}
|
|
})
|
|
}
|
|
}
|