491 lines
17 KiB
Go
491 lines
17 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
type caretTextReader map[atspi.ObjectID]string
|
|
|
|
func (reader caretTextReader) ReadDocumentCaret(_ context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
|
value := reader[reference.ID()]
|
|
runes := []rune(value)
|
|
if offset < 0 || offset >= len(runes) {
|
|
return atspi.DocumentCaretText{CharacterCount: len(runes)}, nil
|
|
}
|
|
if unit == atspi.DocumentCaretCharacter {
|
|
return atspi.DocumentCaretText{Start: offset, End: offset + 1, Text: string(runes[offset]), CharacterCount: len(runes)}, nil
|
|
}
|
|
if unit == atspi.DocumentCaretWord {
|
|
start := offset
|
|
for start > 0 && !unicode.IsSpace(runes[start-1]) {
|
|
start--
|
|
}
|
|
end := offset
|
|
for end < len(runes) && !unicode.IsSpace(runes[end]) {
|
|
end++
|
|
}
|
|
for end < len(runes) && unicode.IsSpace(runes[end]) {
|
|
end++
|
|
}
|
|
return atspi.DocumentCaretText{Start: start, End: end, Text: string(runes[start:end]), CharacterCount: len(runes)}, nil
|
|
}
|
|
start, end := offset, offset
|
|
for start > 0 && runes[start-1] != '\n' {
|
|
start--
|
|
}
|
|
if unit == atspi.DocumentCaretLineRemainder {
|
|
start = offset
|
|
}
|
|
for end < len(runes) && runes[end] != '\n' {
|
|
end++
|
|
}
|
|
if end < len(runes) {
|
|
end++
|
|
}
|
|
return atspi.DocumentCaretText{Start: start, End: end, Text: string(runes[start:end]), CharacterCount: len(runes)}, nil
|
|
}
|
|
|
|
func newCaretTextCursor(t *testing.T, reader caretTextReader) *DocumentCursor {
|
|
t.Helper()
|
|
nodes := []atspi.TreeNode{}
|
|
for _, path := range []string{"/doc", "/text", "/next"} {
|
|
snapshot := atspi.NewSnapshot(path, "", "static", "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
|
|
node := atspi.TreeNode{Snapshot: snapshot}
|
|
if path == "/doc" {
|
|
node.Snapshot.Role = "document web"
|
|
node.Children = []atspi.ObjectID{webID("/text"), webID("/next")}
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(context.Background(), webID("/text")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cursor
|
|
}
|
|
|
|
func TestDocumentCaretFromTrailingNonTextObject(t *testing.T) {
|
|
reader := caretTextReader{webID("/text"): "Last line"}
|
|
for _, test := range []struct {
|
|
command DocumentCaretCommand
|
|
path string
|
|
moved bool
|
|
}{
|
|
{CaretNextLine, "/next", false},
|
|
{CaretPreviousLine, "/text", true},
|
|
{CaretDocumentStart, "/text", true},
|
|
{CaretDocumentEnd, "/text", true},
|
|
} {
|
|
t.Run(fmt.Sprint(test.command), func(t *testing.T) {
|
|
cursor := newCaretTextCursor(t, reader)
|
|
if _, err := cursor.SyncFocus(context.Background(), webID("/next")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.MoveCaret(context.Background(), reader, test.command)
|
|
if err != nil || move.Moved != test.moved || move.Position.Object.ID() != webID(test.path) {
|
|
t.Fatalf("move = %+v, %v", move, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretSkipsUnresolvedEmbeddedCharacters(t *testing.T) {
|
|
reader := caretTextReader{webID("/text"): "A\ufffc\ufeffB", webID("/next"): "C"}
|
|
cursor := newCaretTextCursor(t, reader)
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
text string
|
|
offset int
|
|
}{
|
|
{CaretNextCharacter, "B", 3},
|
|
{CaretPreviousCharacter, "A", 0},
|
|
} {
|
|
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
|
|
if err != nil || !move.Moved || move.Text != step.text || move.Position.Offset != step.offset {
|
|
t.Fatalf("command %v: got %+v, %v", step.command, move, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretMovesFromConversationIntoControls(t *testing.T) {
|
|
reader := caretTextReader{
|
|
webID("/message"): "Last reply",
|
|
webID("/label"): "Voice",
|
|
}
|
|
node := func(path, name, role string, count int, children ...string) atspi.TreeNode {
|
|
snapshot := atspi.NewSnapshot(path, name, role, "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = count
|
|
result := atspi.TreeNode{Snapshot: snapshot}
|
|
for _, child := range children {
|
|
result.Children = append(result.Children, webID(child))
|
|
}
|
|
return result
|
|
}
|
|
entry := node("/composer", "Message", "entry", 0)
|
|
entry.Snapshot.Editable = true
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
|
node("/doc", "", "document web", 0, "/message", "/voice", "/composer"),
|
|
node("/message", "", "static", 10),
|
|
node("/voice", "Voice", "button", 0, "/label"),
|
|
node("/label", "", "static", 5),
|
|
entry,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, webID("/message")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
path string
|
|
moved bool
|
|
}{
|
|
{CaretDocumentEnd, "/composer", true},
|
|
{CaretPreviousLine, "/voice", true},
|
|
{CaretPreviousLine, "/message", true},
|
|
{CaretNextLine, "/voice", true},
|
|
{CaretNextLine, "/composer", true},
|
|
{CaretNextLine, "/composer", false},
|
|
{CaretPreviousCharacter, "/voice", true},
|
|
{CaretNextCharacter, "/composer", true},
|
|
} {
|
|
move, err := cursor.MoveCaret(ctx, reader, step.command)
|
|
if err != nil || move.Moved != step.moved || move.Position.Object.ID() != webID(step.path) {
|
|
t.Fatalf("command %v: got %+v, %v; want %s moved %t", step.command, move, err, step.path, step.moved)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretReachesNamedImageInsideLink(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{webID("/quote_text"): "Quote"}
|
|
node := func(path, name, role string, count int, children ...string) atspi.TreeNode {
|
|
snapshot := atspi.NewSnapshot(path, name, role, "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = count
|
|
result := atspi.TreeNode{Snapshot: snapshot}
|
|
for _, child := range children {
|
|
result.Children = append(result.Children, webID(child))
|
|
}
|
|
return result
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
|
node("/doc", "", "document web", 0, "/quote", "/decorative", "/thumb", "/next"),
|
|
node("/quote", "Quote", "link", 5, "/quote_text"),
|
|
node("/quote_text", "", "static", 5),
|
|
node("/decorative", "", "image", 0),
|
|
node("/thumb", "Thumbs up", "link", 1, "/thumb_image"),
|
|
node("/thumb_image", "Thumbs up", "image", 0),
|
|
node("/next", "Next post", "heading", 0),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, webID("/quote_text")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/thumb_image") {
|
|
t.Fatalf("after Quote: %+v, %v", move, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretExplicitLinkPreservesNameInsteadOfBadge(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{webID("/heading"): "Recent Games", webID("/badge"): "2", webID("/ordinary_text"): "Ordinary link"}
|
|
node := func(path, name, role string, children ...string) atspi.TreeNode {
|
|
snapshot := atspi.NewSnapshot(path, name, role, "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = len([]rune(reader[webID(path)]))
|
|
result := atspi.TreeNode{Snapshot: snapshot}
|
|
for _, child := range children {
|
|
result.Children = append(result.Children, webID(child))
|
|
}
|
|
return result
|
|
}
|
|
card := node("/card", "Example Game 2 copies", "link", "/badge")
|
|
card.Snapshot.ExplicitName = true
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
|
node("/doc", "", "document web", "/heading", "/card", "/ordinary", "/after"),
|
|
node("/heading", "Recent Games", "heading"), card, node("/badge", "", "static"),
|
|
node("/ordinary", "Ordinary link", "link", "/ordinary_text"), node("/ordinary_text", "", "static"),
|
|
node("/after", "After cards", "button"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, webID("/heading")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
path, text string
|
|
}{
|
|
{CaretNextLine, "/card", ""},
|
|
{CaretNextLine, "/ordinary_text", "Ordinary link"},
|
|
{CaretNextCharacter, "/ordinary_text", "r"},
|
|
{CaretPreviousLine, "/card", ""},
|
|
{CaretPreviousLine, "/heading", "Recent Games"},
|
|
} {
|
|
move, err := cursor.MoveCaret(ctx, reader, step.command)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Text != step.text {
|
|
t.Fatalf("%v: %+v, %v; want %s %q", step.command, move, err, step.path, step.text)
|
|
}
|
|
if step.path == "/card" && move.Position.Snapshot.Name != "Example Game 2 copies" {
|
|
t.Fatalf("lost card label: %+v", move.Position.Snapshot)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretKeepsDocumentFocusAfterTopLeafIsRemoved(t *testing.T) {
|
|
reader := caretTextReader{webID("/first"): "First", webID("/next"): "Next"}
|
|
document := atspi.NewSnapshot("/doc", "", "document web", "", "")
|
|
document.BusName = webID("/doc").BusName
|
|
first := atspi.NewSnapshot("/first", "", "static", "", "")
|
|
first.BusName, first.CharacterCount = document.BusName, 5
|
|
next := atspi.NewSnapshot("/next", "", "static", "", "")
|
|
next.BusName, next.CharacterCount = document.BusName, 4
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
|
{Snapshot: document, Children: []atspi.ObjectID{webID("/first"), webID("/next")}},
|
|
{Snapshot: first}, {Snapshot: next},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start, err := cursor.MoveCaret(ctx, reader, CaretDocumentStart)
|
|
if err != nil || start.Position.Object.ID() != webID("/first") {
|
|
t.Fatalf("top of document: %+v, %v", start, err)
|
|
}
|
|
if err := tree.Replace(webID("/doc"), []atspi.TreeNode{
|
|
{Snapshot: document, Children: []atspi.ObjectID{webID("/next")}},
|
|
{Snapshot: next},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatalf("stable document focus lost: %v", err)
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter)
|
|
if err != nil || !move.Moved || move.Text != "N" || move.Position.Object.ID() != webID("/next") {
|
|
t.Fatalf("arrow after removed top leaf: %+v, %v", move, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretPreviousLineSkipsPlaceholderBackwards(t *testing.T) {
|
|
reader := caretTextReader{webID("/text"): "A", webID("/next"): "\ufffc\nB"}
|
|
cursor := newCaretTextCursor(t, reader)
|
|
ctx := context.Background()
|
|
end, err := cursor.MoveCaret(ctx, reader, CaretDocumentEnd)
|
|
if err != nil || end.Text != "B" {
|
|
t.Fatalf("document end: %+v, %v", end, err)
|
|
}
|
|
previous, err := cursor.MoveCaret(ctx, reader, CaretPreviousLine)
|
|
if err != nil || !previous.Moved || previous.Text != "A" {
|
|
t.Fatalf("previous line across placeholder: %+v, %v", previous, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretWordDirectionAndWhitespace(t *testing.T) {
|
|
reader := caretTextReader{webID("/text"): "Alpha beta 猫", webID("/next"): "Next"}
|
|
cursor := newCaretTextCursor(t, reader)
|
|
ctx := context.Background()
|
|
// Python nextContext/previousContext starts one character away, skips
|
|
// whitespace, and then chooses the containing word, even on reversal.
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
path string
|
|
offset int
|
|
text string
|
|
}{
|
|
{CaretNextWord, "/text", 5, "Alpha "},
|
|
{CaretNextWord, "/text", 11, "beta "},
|
|
{CaretPreviousWord, "/text", 6, "beta "},
|
|
{CaretPreviousWord, "/text", 0, "Alpha "},
|
|
{CaretNextWord, "/text", 5, "Alpha "},
|
|
{CaretNextWord, "/text", 11, "beta "},
|
|
{CaretNextWord, "/text", 13, "猫"},
|
|
{CaretNextWord, "/next", 4, "Next"},
|
|
{CaretPreviousCharacter, "/next", 3, "t"},
|
|
{CaretPreviousWord, "/next", 0, "Next"},
|
|
{CaretPreviousWord, "/text", 12, "猫"},
|
|
} {
|
|
move, err := cursor.MoveCaret(ctx, reader, step.command)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset || move.Text != step.text {
|
|
t.Fatalf("command %v: got %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text)
|
|
}
|
|
}
|
|
}
|
|
|
|
type invalidWordReader struct {
|
|
caretTextReader
|
|
word atspi.DocumentCaretText
|
|
err error
|
|
}
|
|
|
|
func (reader invalidWordReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
|
if reference.ID() == webID("/text") && unit == atspi.DocumentCaretWord {
|
|
return reader.word, reader.err
|
|
}
|
|
return reader.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit)
|
|
}
|
|
|
|
func TestDocumentCaretWordAcrossInvalidEmojiRange(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "🧻", webID("/next"): "After emoji"}
|
|
for _, test := range []struct {
|
|
name string
|
|
word atspi.DocumentCaretText
|
|
}{
|
|
{"negative end", atspi.DocumentCaretText{Start: 0, End: -1, Text: "�", CharacterCount: 1}},
|
|
{"empty range", atspi.DocumentCaretText{CharacterCount: 1}},
|
|
{"range outside text", atspi.DocumentCaretText{Start: 0, End: 2, Text: "�", CharacterCount: 1}},
|
|
{"empty text", atspi.DocumentCaretText{Start: 0, End: 1, CharacterCount: 1}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
reader := invalidWordReader{caretTextReader: text, word: test.word}
|
|
cursor := newCaretTextCursor(t, text)
|
|
ctx := context.Background()
|
|
if _, err := cursor.MoveCaret(ctx, reader, CaretDocumentEnd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
path string
|
|
offset int
|
|
text string
|
|
}{
|
|
{CaretPreviousWord, "/next", 6, "emoji"},
|
|
{CaretPreviousWord, "/next", 0, "After "},
|
|
{CaretPreviousWord, "/text", 0, "🧻"},
|
|
{CaretNextWord, "/next", 5, "After "},
|
|
{CaretPreviousWord, "/next", 0, "After "},
|
|
{CaretPreviousWord, "/text", 0, "🧻"},
|
|
} {
|
|
move, err := cursor.MoveCaret(ctx, reader, step.command)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset || move.Text != step.text {
|
|
t.Fatalf("%v: got %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretWordPreservesNativeErrors(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "🧻🧻", webID("/next"): "After"}
|
|
for _, nativeErr := range []error{atspi.ErrStaleObject, context.Canceled, atspi.ErrNoDocumentCaret} {
|
|
reader := invalidWordReader{caretTextReader: text, err: nativeErr}
|
|
cursor := newCaretTextCursor(t, text)
|
|
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextWord)
|
|
if !errors.Is(err, nativeErr) || move.Moved {
|
|
t.Fatalf("native error %v: got %+v, %v", nativeErr, move, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretContinuesFromStructuralHeading(t *testing.T) {
|
|
reader := caretTextReader{
|
|
webID("/first_text"): "First heading",
|
|
webID("/old_text"): "Old position",
|
|
webID("/second_text"): "Second heading",
|
|
webID("/next_text"): "Text after second heading",
|
|
}
|
|
node := func(path, role string, children ...string) atspi.TreeNode {
|
|
snapshot := atspi.NewSnapshot(path, path, role, "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
|
|
if role == "heading" {
|
|
snapshot.HeadingLevel = 2
|
|
}
|
|
result := atspi.TreeNode{Snapshot: snapshot}
|
|
for _, child := range children {
|
|
result.Children = append(result.Children, webID(child))
|
|
}
|
|
return result
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/root"), []atspi.TreeNode{
|
|
node("/root", "frame", "/doc"),
|
|
node("/doc", "document web", "/first", "/old", "/second", "/next"),
|
|
node("/first", "heading", "/first_text"),
|
|
node("/first_text", "static"),
|
|
node("/old", "paragraph", "/old_text"),
|
|
node("/old_text", "static"),
|
|
node("/second", "heading", "/second_text"),
|
|
node("/second_text", "static"),
|
|
node("/next", "paragraph", "/next_text"),
|
|
node("/next_text", "static"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, webID("/old_text")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jump, err := cursor.Move(ctx, DocumentHeading, 0, false)
|
|
if err != nil || jump.Position.Object.ID() != webID("/second") {
|
|
t.Fatalf("heading jump = %+v, %v", jump, err)
|
|
}
|
|
line, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || !line.Moved || line.Text != "Text after second heading" {
|
|
t.Fatalf("Down after heading = %+v, %v", line, err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, webID("/next_text")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
character, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter)
|
|
if err != nil || character.Text != "e" || character.Position.Offset != 1 {
|
|
t.Fatalf("Right after line = %+v, %v", character, err)
|
|
}
|
|
start, err := cursor.MoveCaret(ctx, reader, CaretDocumentStart)
|
|
if err != nil || start.Text != "First heading" || start.Position.Object.ID() != webID("/first_text") {
|
|
t.Fatalf("Ctrl+Home = %+v, %v", start, err)
|
|
}
|
|
}
|