Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
225 lines
7.7 KiB
Go
225 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"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// Include focus synchronization and worker bookkeeping, which the primitive
|
|
// heading benchmark intentionally excludes. Native provider work is separate.
|
|
func BenchmarkDocumentWorkerHeadingNavigation(b *testing.B) {
|
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
|
tree, ids := benchmark_document_tree(b, contentNodes)
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
worker, err := NewWorker(&fakeCapturer{}, time.Second)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
middle := contentNodes / 40 * 20
|
|
worker.SyncFocus(ids[middle])
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, ids[middle]); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
b.ReportAllocs()
|
|
for b.Loop() {
|
|
for _, backwards := range []bool{false, true} {
|
|
result, next := worker.moveDocument(ctx, cursor, DocumentHeading, 2, backwards, 0, 0)
|
|
cursor = next
|
|
want := ids[middle+20]
|
|
if backwards {
|
|
want = ids[middle]
|
|
}
|
|
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != want || result.DocumentDiagnostics.CaptureAttempts != 0 {
|
|
b.Fatalf("worker move: moved=%v error=%v", result.Moved, result.Err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkDocumentCaretNavigation(b *testing.B) {
|
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
|
tree, ids := benchmark_document_tree(b, contentNodes)
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
middle := contentNodes/2 + 2
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, ids[middle]); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
reader := caretTextReader{ids[middle]: fmt.Sprintf("Café sample %d", middle)}
|
|
// Build the view's reusable ranges before measuring repeated keys.
|
|
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
|
|
if _, err := cursor.MoveCaret(ctx, reader, command); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
b.ReportAllocs()
|
|
for b.Loop() {
|
|
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
|
|
move, err := cursor.MoveCaret(ctx, reader, command)
|
|
want := 1
|
|
if command == CaretPreviousCharacter {
|
|
want = 0
|
|
}
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != ids[middle] || move.Position.Offset != want {
|
|
b.Fatalf("caret move: moved=%v error=%v", move.Moved, err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// benchmark_document_tree models an already captured page, not native AT-SPI
|
|
// capture. Each section has twenty content objects: a heading, a link, and
|
|
// ordinary text. Pairs of objects share a visual row, including Unicode text.
|
|
func benchmark_document_tree(b *testing.B, contentNodes int) (*atspi.AccessibleTree, []atspi.ObjectID) {
|
|
b.Helper()
|
|
rootID := webID("/benchmark/document")
|
|
root := atspi.NewSnapshot(rootID.Path, "Benchmark page", "document web", "", "")
|
|
root.BusName = rootID.BusName
|
|
nodes := []atspi.TreeNode{{Snapshot: root}}
|
|
contentIDs := make([]atspi.ObjectID, contentNodes)
|
|
sectionIndex := 0
|
|
for index := range contentNodes {
|
|
if index%20 == 0 {
|
|
sectionID := webID(fmt.Sprintf("/benchmark/section_%d", index/20))
|
|
section := atspi.NewSnapshot(sectionID.Path, "", "section", "", "")
|
|
section.BusName = sectionID.BusName
|
|
nodes[0].Children = append(nodes[0].Children, sectionID)
|
|
sectionIndex = len(nodes)
|
|
nodes = append(nodes, atspi.TreeNode{Snapshot: section})
|
|
}
|
|
id := webID(fmt.Sprintf("/benchmark/content_%d", index))
|
|
contentIDs[index] = id
|
|
role, text := "static", fmt.Sprintf("Café sample %d", index)
|
|
switch index % 20 {
|
|
case 0:
|
|
role, text = "heading", fmt.Sprintf("Heading %d", index/20)
|
|
case 1:
|
|
role, text = "link", fmt.Sprintf("Link %d", index/20)
|
|
}
|
|
snapshot := atspi.NewSnapshot(id.Path, text, role, "", text)
|
|
snapshot.BusName = id.BusName
|
|
snapshot.CharacterCount = utf8.RuneCountInString(text)
|
|
if role == "heading" {
|
|
snapshot.HeadingLevel = 2
|
|
}
|
|
bounds := atspi.Rectangle{X: int32(index%2) * 200, Y: int32(index/2) * 24, Width: 180, Height: 20}
|
|
nodes[sectionIndex].Children = append(nodes[sectionIndex].Children, id)
|
|
nodes = append(nodes, atspi.TreeNode{
|
|
Snapshot: snapshot,
|
|
Geometry: atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true},
|
|
Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{
|
|
Start: 0, End: snapshot.CharacterCount, Text: text, Bounds: bounds,
|
|
}}},
|
|
})
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(rootID, nodes)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
return tree, contentIDs
|
|
}
|
|
|
|
func BenchmarkDocumentSnapshot(b *testing.B) {
|
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
|
tree, _ := benchmark_document_tree(b, contentNodes)
|
|
root, err := tree.Root()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
wantNodes := contentNodes + contentNodes/20 + 1
|
|
b.ReportAllocs()
|
|
for b.Loop() {
|
|
document, err := tree.Document(ctx, root)
|
|
if err != nil || len(document.Nodes) != wantNodes {
|
|
b.Fatalf("document snapshot: nodes=%d, error=%v", len(document.Nodes), err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkDocumentHeadingNavigation(b *testing.B) {
|
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
|
for _, scenario := range []string{"adjacent", "wrap"} {
|
|
b.Run(fmt.Sprintf("nodes-%d/%s", contentNodes, scenario), func(b *testing.B) {
|
|
tree, contentIDs := benchmark_document_tree(b, contentNodes)
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
startIndex := contentNodes / 40 * 20
|
|
wantWrapped := scenario == "wrap"
|
|
if wantWrapped {
|
|
startIndex = contentNodes - 20
|
|
}
|
|
nextIndex := (startIndex + 20) % contentNodes
|
|
if _, err := cursor.SyncFocus(ctx, contentIDs[startIndex]); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
b.ReportAllocs()
|
|
for b.Loop() {
|
|
// One operation is a round trip. Returning to the same heading
|
|
// keeps the workload independent of the calibrated loop count.
|
|
move, err := cursor.Move(ctx, DocumentHeading, 2, false)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != contentIDs[nextIndex] || move.Wrapped != wantWrapped {
|
|
b.Fatalf("forward heading navigation: moved=%v, error=%v", move.Moved, err)
|
|
}
|
|
move, err = cursor.Move(ctx, DocumentHeading, 2, true)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != contentIDs[startIndex] || move.Wrapped != wantWrapped {
|
|
b.Fatalf("backward heading navigation: moved=%v, error=%v", move.Moved, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkDocumentLineAssembly(b *testing.B) {
|
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
|
tree, contentIDs := benchmark_document_tree(b, contentNodes)
|
|
middle := contentNodes / 2
|
|
reference, err := tree.Reference(contentIDs[middle])
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
b.ReportAllocs()
|
|
for b.Loop() {
|
|
line, err := tree.DocumentLineAtOffset(ctx, reference, 1)
|
|
if err != nil || len(line.Contents) != 2 {
|
|
b.Fatalf("line assembly: contents=%d, error=%v", len(line.Contents), err)
|
|
}
|
|
if line.Contents[0].Reference.ID() != contentIDs[middle] || line.Contents[1].Reference.ID() != contentIDs[middle+1] {
|
|
b.Fatal("line assembly returned the wrong visual row")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|