// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "testing" "time" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) func glyph_cursor(t *testing.T, parentRole string, modify func(*atspi.Snapshot)) (*DocumentCursor, caretTextReader) { t.Helper() reader := caretTextReader{webID("/before"): "Member", webID("/icon"): "\uf005", webID("/after"): "Join Date"} node := func(path, role string, children ...string) atspi.TreeNode { text := reader[webID(path)] snapshot := atspi.NewSnapshot(path, text, role, "", text) snapshot.BusName, snapshot.StatesKnown = webID(path).BusName, true snapshot.CharacterCount = utf8.RuneCountInString(text) result := atspi.TreeNode{Snapshot: snapshot} for _, child := range children { result.Children = append(result.Children, webID(child)) } return result } root := node("/doc", "document web", "/before", "/rank", "/after") root.Snapshot.Focusable = true // A focusable document must not protect every icon. parent := node("/rank", parentRole, "/icon") if parentRole == "pre" || parentRole == "code" { parent.Snapshot.HTMLTag = parentRole } icon := node("/icon", "static") if modify != nil { modify(&icon.Snapshot) } tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ root, node("/before", "static"), parent, icon, 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("/before")); err != nil { t.Fatal(err) } return cursor, reader } func TestDocumentCaretSkipsDecorativeGlyph(t *testing.T) { ctx := context.Background() for _, command := range []DocumentCaretCommand{CaretNextLine, CaretNextCharacter, CaretNextWord} { t.Run(command.String(), func(t *testing.T) { cursor, reader := glyph_cursor(t, "section", nil) if command != CaretNextLine { cursor.places[0].offset = len("Member") - 1 } move, err := cursor.MoveCaret(ctx, reader, command) if err != nil || !move.Moved || move.Position.Object.ID() != webID("/after") { t.Fatalf("forward: %+v, %v", move, err) } move, err = cursor.MoveCaret(ctx, reader, CaretPreviousLine) if err != nil || !move.Moved || move.Text != "Member" { t.Fatalf("backward: %+v, %v", move, err) } }) } // Focus/routing may leave the cursor on an omitted icon. Both directions // must still reach surrounding text rather than report a stale position. for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine} { cursor, reader := glyph_cursor(t, "section", nil) if _, err := cursor.SyncFocus(ctx, webID("/icon")); err != nil { t.Fatal(err) } move, err := cursor.MoveCaret(ctx, reader, command) want := "Join Date" if command == CaretPreviousLine { want = "Member" } if err != nil || !move.Moved || move.Text != want { t.Fatalf("from icon %s: %+v, %v", command, move, err) } } } func TestDocumentCaretPreservesMeaningfulGlyphs(t *testing.T) { for _, test := range []struct { name, parentRole string modify func(*atspi.Snapshot) }{ {"link", "link", nil}, {"code", "code", nil}, {"pre", "pre", nil}, {"focusable", "section", func(s *atspi.Snapshot) { s.Focusable = true }}, {"clickable", "section", func(s *atspi.Snapshot) { s.SupportsClick = true }}, {"editable", "section", func(s *atspi.Snapshot) { s.Editable, s.Role = true, "entry" }}, {"named", "section", func(s *atspi.Snapshot) { s.ExplicitName = true }}, {"description", "section", func(s *atspi.Snapshot) { s.Description = "Rating" }}, {"unknown states", "section", func(s *atspi.Snapshot) { s.StatesKnown = false }}, {"ordinary symbol", "section", func(s *atspi.Snapshot) { s.Name, s.Text = "★", "★" }}, {"mixed text", "section", func(s *atspi.Snapshot) { s.Name, s.Text, s.CharacterCount = "Star \uf005", "Star \uf005", 6 }}, {"different text", "section", func(s *atspi.Snapshot) { s.Text = "X" }}, } { t.Run(test.name, func(t *testing.T) { cursor, reader := glyph_cursor(t, test.parentRole, test.modify) move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) if err != nil || !move.Moved || move.Position.Object.ID() != webID("/icon") { t.Fatalf("meaningful glyph omitted: %+v, %v", move, err) } }) } } func TestReadAllSkipsDecorativeGlyph(t *testing.T) { cursor, reader := glyph_cursor(t, "section", nil) capturer := &readAllCapturer{tree: cursor.tree, caretTextReader: reader} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/before")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) != 2 { t.Fatalf("Read All: %+v", result) } for index, want := range []string{"Member", "Join Date"} { if got := result.ReadAllLines[index].Contents[0].Text; got != want { t.Fatalf("line %d: %q, want %q", index, got, want) } } }