// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "errors" "strings" "testing" "time" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) type fragmentTextReader struct { caretTextReader bounds map[atspi.ObjectID]atspi.Rectangle err error } func (reader fragmentTextReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { if reader.err != nil { return atspi.DocumentCaretText{}, reader.err } text, err := reader.caretTextReader.ReadDocumentCaret(ctx, ref, offset, unit) text.RawText, text.Bounds = text.Text, reader.bounds[ref.ID()] return text, err } func fragment_cursor(t *testing.T, boundary string) (*DocumentCursor, fragmentTextReader) { t.Helper() reader := fragmentTextReader{caretTextReader: caretTextReader{ webID("/one"): "One ", webID("/two"): "two.", webID("/three"): "Three ", webID("/four"): "four.", webID("/after"): "Next paragraph", }, bounds: make(map[atspi.ObjectID]atspi.Rectangle)} for _, row := range []struct { path string x, y int32 }{{"/one", 0, 10}, {"/two", 40, 10}, {"/three", 0, 30}, {"/four", 50, 30}, {"/after", 100, 30}} { reader.bounds[webID(row.path)] = atspi.Rectangle{X: row.x, Y: row.y, Width: 30, Height: 18} } node := func(path, role string, children ...string) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, "", role, "", "") snapshot.BusName, snapshot.CharacterCount = webID(path).BusName, utf8.RuneCountInString(reader.caretTextReader[webID(path)]) result := atspi.TreeNode{Snapshot: snapshot} for _, child := range children { result.Children = append(result.Children, webID(child)) } return result } block := node("/paragraph", boundary, "/one", "/span", "/three", "/four") span := node("/span", "section", "/two") span.Snapshot.HTMLTag = "span" if boundary == "pre" || boundary == "code" { block.Snapshot.HTMLTag = boundary } if boundary == "entry" { block.Snapshot.Editable = true } tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ node("/doc", "document web", "/paragraph", "/next"), block, node("/one", "static"), span, node("/two", "static"), node("/three", "static"), node("/four", "static"), node("/next", "paragraph", "/after"), node("/after", "static"), }) if err != nil { t.Fatal(err) } cursor, err := NewDocumentCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(context.Background(), webID("/one")); err != nil { t.Fatal(err) } return cursor, reader } func TestDocumentFragmentLines(t *testing.T) { cursor, reader := fragment_cursor(t, "paragraph") ctx := context.Background() for _, step := range []struct { command DocumentCaretCommand path, text string offset int }{ {CaretDocumentStart, "/one", "One two.", 0}, {CaretNextCharacter, "/one", "n", 1}, {CaretNextLine, "/three", "Three four.", 0}, {CaretPreviousLine, "/one", "One two.", 0}, {CaretLineEnd, "/two", ".", 3}, {CaretLineStart, "/one", "O", 0}, {CaretNextLine, "/three", "Three four.", 0}, {CaretNextLine, "/after", "Next paragraph", 0}, {CaretPreviousLine, "/three", "Three four.", 0}, } { move, err := cursor.MoveCaret(ctx, reader, step.command) if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset { t.Fatalf("%s: %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text) } if move.End > 0 && move.End > move.Position.Snapshot.CharacterCount { t.Fatal("joined offset escaped native object") } } } func TestDocumentFragmentSpacingAndPunctuation(t *testing.T) { for _, test := range []struct { first, second, want string x int32 }{ {"One ", "two.", "One two.", 40}, {"One", "two.", "One two.", 40}, {"Hello", ",", "Hello,", 30}, } { cursor, reader := fragment_cursor(t, "paragraph") reader.caretTextReader[webID("/one")] = test.first reader.caretTextReader[webID("/two")] = test.second bounds := reader.bounds[webID("/two")] bounds.X = test.x reader.bounds[webID("/two")] = bounds // Range lengths come from the provider, just as in a native capture. doc, _ := cursor.tree.Document(context.Background(), cursor.places[0].object) var nodes []atspi.TreeNode for _, node := range doc.Nodes { if text, ok := reader.caretTextReader[node.Reference.ID()]; ok { node.Snapshot.CharacterCount = utf8.RuneCountInString(text) } nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children}) } if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart) if err != nil || move.Text != test.want { t.Fatalf("fragments %q/%q: %+v, %v; want %q", test.first, test.second, move, err, test.want) } } } type fragmentReadAllCapturer struct { *readAllCapturer fragmentTextReader } func (capturer *fragmentReadAllCapturer) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { return capturer.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit) } func TestReadAllFragmentLinesAndRemainder(t *testing.T) { cursor, reader := fragment_cursor(t, "paragraph") ctx := context.Background() if _, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter); err != nil { t.Fatal(err) } capturer := &fragmentReadAllCapturer{readAllCapturer: &readAllCapturer{tree: cursor.tree}, fragmentTextReader: reader} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/one")) result := worker.readAll(ctx, cursor) if result.Err != nil || len(result.ReadAllLines) != 3 { t.Fatalf("read all: %+v", result) } for index, want := range []string{"ne two.", "Three four.", "Next paragraph"} { if got := result.ReadAllLines[index].Contents[0].Text; got != want { t.Fatalf("line %d: %q, want %q", index, got, want) } } if cursor.places[0].offset != 1 { t.Fatal("assembly moved the user's cursor") } result.ReadAllProgress(1) request := <-worker.requests followed, ok := worker.followReadAll(ctx, cursor, request) if !ok { t.Fatal("progress lost ownership") } move, err := followed.MoveCaret(ctx, reader, CaretNextLine) if err != nil || move.Text != "Next paragraph" { t.Fatalf("Down after progress: %+v, %v", move, err) } } func TestDocumentFragmentBoundaries(t *testing.T) { for _, boundary := range []string{"heading", "link", "table cell", "pre", "code", "entry"} { t.Run(boundary, func(t *testing.T) { cursor, reader := fragment_cursor(t, boundary) move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart) want := "One two." if boundary == "pre" || boundary == "code" || boundary == "entry" { want = "One " } if boundary == "entry" { want = "" } if err != nil || move.Text != want { t.Fatalf("%s: %+v, %v; want %q", boundary, move, err, want) } }) } } func TestDocumentFragmentErrorsDoNotMoveCursor(t *testing.T) { for _, failure := range []error{context.Canceled, atspi.ErrStaleObject} { cursor, reader := fragment_cursor(t, "paragraph") reader.err = failure before := cursor.places[0] _, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) if !errors.Is(err, failure) || cursor.places[0] != before { t.Fatalf("error %v: %v; changed cursor", failure, err) } } } type wrappedFragmentReader struct{ fragmentTextReader } func (reader wrappedFragmentReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { text, err := reader.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit) if ref.ID() == webID("/one") && offset >= 6 { text.Bounds.Y = 30 } return text, err } func TestDocumentFragmentLineAcrossWrappedObject(t *testing.T) { cursor, base := fragment_cursor(t, "paragraph") base.caretTextReader[webID("/one")] = "First\nSecond " base.bounds[webID("/two")] = atspi.Rectangle{X: 60, Y: 30, Width: 30, Height: 18} base.bounds[webID("/three")] = atspi.Rectangle{X: 0, Y: 50, Width: 30, Height: 18} base.bounds[webID("/four")] = atspi.Rectangle{X: 50, Y: 50, Width: 30, Height: 18} ref, _ := cursor.tree.Reference(webID("/one")) doc, err := cursor.tree.Document(context.Background(), ref) if err != nil { t.Fatal(err) } nodes := make([]atspi.TreeNode, 0, len(doc.Nodes)) for _, node := range doc.Nodes { if node.Reference == ref { node.Snapshot.CharacterCount = utf8.RuneCountInString(base.caretTextReader[webID("/one")]) } nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children}) } if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } reader := wrappedFragmentReader{base} for _, step := range []struct { command DocumentCaretCommand want string }{ {CaretDocumentStart, "First"}, {CaretNextLine, "Second two."}, {CaretPreviousLine, "First"}, {CaretNextLine, "Second two."}, {CaretNextLine, "Three four."}, } { move, err := cursor.MoveCaret(context.Background(), reader, step.command) if err != nil || strings.TrimSpace(move.Text) != step.want { t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want) } } }