// 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) } }