// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package review import ( "context" "errors" "strings" "testing" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) func flatNode(path, text string, x, y int32) atspi.TreeNode { n := node(path, "") n.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: x, Y: y, Width: 200, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} n.Text = atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Text: text, Start: 0, End: utf8.RuneCountInString(text), Bounds: n.Geometry.Bounds}}} return n } func flatTree(t *testing.T, nodes ...atspi.TreeNode) *atspi.AccessibleTree { t.Helper() root := node("/window", "Example Window") root.Snapshot.Role = "frame" root.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: -500, Y: -500, Width: 2000, Height: 2000}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} for _, n := range nodes { root.Children = append(root.Children, n.Snapshot.ID()) } tree, err := atspi.NewAccessibleTree(objectID("/window"), append([]atspi.TreeNode{root}, nodes...)) if err != nil { t.Fatal(err) } return tree } func TestFlatReviewSpeaksSharedHeadingTextOnce(t *testing.T) { heading := flatNode("/heading", "Reading section", 0, 0) heading.Snapshot.Role = "heading" heading.Children = []atspi.ObjectID{objectID("/text")} child := flatNode("/text", "Reading section", 0, 0) child.Snapshot.Role = "static" root := node("/window", "") root.Snapshot.Role = "frame" root.Geometry = heading.Geometry root.Children = []atspi.ObjectID{objectID("/heading")} tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{root, heading, child}) if err != nil { t.Fatal(err) } cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/heading")); err != nil { t.Fatal(err) } position, _, err := cursor.Read(Line, 0) if err != nil || position.Text != "Reading section" { t.Fatalf("repeated heading text: position=%+v err=%v", position, err) } } func TestFlatReviewPageTabHeaderDoesNotClipPageContent(t *testing.T) { frame := node("/window", "") frame.Snapshot.Role = "frame" frame.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 300, Height: 200}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} frame.Children = []atspi.ObjectID{objectID("/tab"), objectID("/hidden_tab")} tab := node("/tab", "") tab.Snapshot.Role = "page tab" tab.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 100, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} tab.Children = []atspi.ObjectID{objectID("/editor")} editor := flatNode("/editor", "Alpha beta", 0, 40) editor.Snapshot.Role = "text" hiddenTab := node("/hidden_tab", "") hiddenTab.Snapshot.Role = "page tab" hiddenTab.Geometry = atspi.Geometry{VisibilityKnown: true, Visible: true, Showing: false} hiddenTab.Children = []atspi.ObjectID{objectID("/hidden_editor")} hiddenEditor := flatNode("/hidden_editor", "Should stay hidden", 0, 80) tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{frame, tab, editor, hiddenTab, hiddenEditor}) if err != nil { t.Fatal(err) } cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/editor")); err != nil { t.Fatal(err) } for _, tc := range []struct { unit FlatUnit step int text string moved bool }{ {Word, 0, "Alpha ", false}, {Character, 1, "l", true}, {Word, 1, "beta", true}, } { position, moved, err := cursor.Read(tc.unit, tc.step) if err != nil || position.Object != objectID("/editor") || position.Text != tc.text || moved != tc.moved { t.Fatalf("unit=%v step=%d: position=%+v moved=%v err=%v", tc.unit, tc.step, position, moved, err) } } if position, moved, err := cursor.Read(Line, 1); err != nil || moved || position.Object != objectID("/editor") { t.Fatalf("hidden page entered review: position=%+v moved=%v err=%v", position, moved, err) } } func TestFlatReadingOrderAndUnicodeNavigation(t *testing.T) { // Tree order deliberately differs from the screen order. tree := flatTree(t, flatNode("/bottom", "next", 0, 40), flatNode("/right", "right", 250, 2), flatNode("/left", "Γ©πŸ™‚ one", -100, 0)) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/left")); err != nil { t.Fatal(err) } for _, tc := range []struct { unit FlatUnit step int text string offset int moved bool }{ {Line, 0, "Γ©πŸ™‚ one right", 0, false}, {Character, 1, "πŸ™‚", 1, true}, {Character, 1, " ", 2, true}, {Word, 1, "one", 3, true}, {Word, 1, "right", 0, true}, {Word, 1, "next", 0, true}, {Line, 1, "next", 0, false}, {Character, -1, "t", 4, true}, {Line, -1, "Γ©πŸ™‚ one right", 4, false}, } { got, moved, err := cursor.Read(tc.unit, tc.step) if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved || !got.HasOffset { t.Fatalf("unit=%d step=%d: %#v moved=%v err=%v", tc.unit, tc.step, got, moved, err) } } } func TestFlatWindowStartAndEnd(t *testing.T) { tree := flatTree(t, flatNode("/first", "first", 0, 0), flatNode("/last", "last", 0, 40)) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/last")); err != nil { t.Fatal(err) } if got, moved, err := cursor.Read(WindowStart, 0); err != nil || !moved || got.Text != "first" { t.Fatalf("start: %#v moved=%v err=%v", got, moved, err) } if got, moved, err := cursor.Read(WindowEnd, 0); err != nil || !moved || got.Text != "last" { t.Fatalf("end: %#v moved=%v err=%v", got, moved, err) } if got, moved, err := cursor.Read(Character, 0); err != nil || moved || got.Text != "t" { t.Fatalf("end character: %#v moved=%v err=%v", got, moved, err) } } func TestFlatVerticalWordsAndLineEnd(t *testing.T) { bottom := flatNode("/bottom", "four", 100, 40) firstTop := flatNode("/first", "one", 0, 0) firstTop.Geometry.Bounds.Width = 100 firstTop.Text.Ranges[0].Bounds.Width = 100 secondTop := flatNode("/second", "two", 100, 0) tree := flatTree(t, firstTop, secondTop, bottom) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil { t.Fatal(err) } if _, moved, err := cursor.Read(Character, 1); err != nil || !moved { t.Fatalf("position character: moved=%v err=%v", moved, err) } got, moved, err := cursor.Read(WordAbove, 0) if err != nil || !moved || got.Object != objectID("/second") || got.Text != "two" || got.Offset != 0 { t.Fatalf("above: got=%+v moved=%v err=%v", got, moved, err) } got, moved, err = cursor.Read(WordBelow, 0) if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "four" || got.Offset != 0 { t.Fatalf("below: got=%+v moved=%v err=%v", got, moved, err) } got, moved, err = cursor.Read(LineEnd, 0) if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "r" || got.Offset != 3 { t.Fatalf("line end: got=%+v moved=%v err=%v", got, moved, err) } got, moved, err = cursor.Read(LineEnd, 0) if err != nil || moved || got.Text != "r" || got.Offset != 3 { t.Fatalf("line end repeat: got=%+v moved=%v err=%v", got, moved, err) } if _, _, err := cursor.Read(WindowStart, 0); err != nil { t.Fatal(err) } got, moved, err = cursor.Read(WordAbove, 0) if err != nil || moved || got.Text != "one" { t.Fatalf("top boundary: got=%+v moved=%v err=%v", got, moved, err) } } func TestFlatVerticalMovementPreservesHorizontalTarget(t *testing.T) { top := flatNode("/top", "abcd", 0, 0) middle := flatNode("/middle", "ab", 0, 40) bottom := flatNode("/bottom", "abcd", 0, 80) for _, node := range []*atspi.TreeNode{&top, &middle, &bottom} { node.Geometry.Bounds.Width = 100 node.Text.Ranges[0].Bounds.Width = 100 } tree := flatTree(t, top, middle, bottom) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil { t.Fatal(err) } if _, moved, err := cursor.Read(Character, 1); err != nil || !moved { t.Fatalf("position character: moved=%v err=%v", moved, err) } if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved { t.Fatalf("first below: moved=%v err=%v", moved, err) } // The worker refreshes the cursor before every request. The original // horizontal target must survive that refresh for consecutive moves. if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil { t.Fatal(err) } if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved { t.Fatalf("second below: moved=%v err=%v", moved, err) } got, _, err := cursor.Read(Character, 0) if err != nil || got.Object != objectID("/bottom") || got.Text != "b" || got.Offset != 1 { t.Fatalf("preserved target: got=%+v err=%v", got, err) } for _, tc := range []struct { unit FlatUnit step int object string character string }{ {WordAbove, 0, "/middle", "a"}, {WordAbove, 0, "/top", "b"}, {LineEnd, 0, "/top", "d"}, {WordBelow, 0, "/middle", "b"}, {WordBelow, 0, "/bottom", "d"}, {Character, -1, "/bottom", "c"}, {WordAbove, 0, "/middle", "b"}, {WordAbove, 0, "/top", "c"}, } { if _, _, err := cursor.Read(tc.unit, tc.step); err != nil { t.Fatal(err) } got, _, err := cursor.Read(Character, 0) if err != nil || got.Object != objectID(tc.object) || got.Text != tc.character { t.Fatalf("unit %d: got=%+v err=%v", tc.unit, got, err) } } if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil { t.Fatal(err) } if _, _, err := cursor.Read(WordAbove, 0); err != nil { t.Fatal(err) } got, _, err = cursor.Read(Character, 0) if err != nil || got.Text != "a" { t.Fatalf("changed focus retained old target: got=%+v err=%v", got, err) } } func TestFlatRefreshPreservesPositionAndHonorsChangedFocus(t *testing.T) { tree := flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "bπŸ™‚", 0, 30)) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } cursor.Read(Line, 1) cursor.Read(Character, 1) if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } got, _, _ := cursor.Read(Character, 0) if got.Object != objectID("/b") || got.Text != "πŸ™‚" || got.Offset != 1 { t.Fatalf("lost independent position: %#v", got) } tree.InvalidateText(objectID("/b")) if err := cursor.Refresh(context.Background(), tree, objectID("/a")); !errors.Is(err, atspi.ErrStaleText) { t.Fatal(err) } got, _, _ = cursor.Read(Character, 0) if got.Text != "πŸ™‚" { t.Fatal("failed refresh mutated position") } tree = flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "bπŸ™‚", 0, 30), flatNode("/c", "c", 0, 60)) if err := cursor.Refresh(context.Background(), tree, objectID("/c")); err != nil { t.Fatal(err) } got, _, _ = cursor.Read(Character, 0) if got.Text != "c" { t.Fatalf("changed focus ignored: %#v", got) } } func TestFlatSyntheticNamesHiddenAndPasswordText(t *testing.T) { password := flatNode("/password", "secret", 0, 30) password.Snapshot.Role = "password text" password.Snapshot.Name = "Password" hidden := node("/hidden", "Hidden content") button := flatNode("/button", "unused", 0, 0) button.Text = atspi.VisibleText{} button.Snapshot.Name = "Save" tree := flatTree(t, button, password, hidden) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/button")); err != nil { t.Fatal(err) } got, _, _ := cursor.Read(Word, 0) if got.Text != "Save" || got.HasOffset { t.Fatalf("synthetic name: %#v", got) } got, _, _ = cursor.Read(Line, 1) if got.Text != "Password" || got.HasOffset { t.Fatalf("password: %#v", got) } if len(cursor.lines) != 2 { t.Fatalf("hidden/container content leaked: %#v", cursor.lines) } } func TestFlatClusteringDoesNotChainRowsAndKeepsRTLLogicalText(t *testing.T) { nodes := []atspi.TreeNode{flatNode("/a", "אבג", 0, 0), flatNode("/b", "b", 250, 5), flatNode("/c", "c", 500, 10)} lines, err := buildFlatLines(context.Background(), nodes) if err != nil || len(lines) != 2 || len(lines[0].zones) != 2 || lines[0].zones[0].text != "אבג" { t.Fatalf("lines=%#v err=%v", lines, err) } } func TestFlatRejectsLimitedEmptyAndInvalidRequests(t *testing.T) { limited := flatNode("/large", "x", 0, 0) limited.Text = atspi.VisibleText{Limited: true} cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), flatTree(t, limited), objectID("/large")); !errors.Is(err, ErrLimitedReview) { t.Fatal(err) } if _, _, err := cursor.Read(Line, 0); !errors.Is(err, ErrNoReviewContent) { t.Fatal(err) } if _, _, err := cursor.Read(0, 0); !errors.Is(err, ErrInvalidDirection) { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := buildFlatLines(ctx, []atspi.TreeNode{limited}); !errors.Is(err, context.Canceled) { t.Fatal(err) } } func TestFlatWordWhitespaceAndCharacterBoundaries(t *testing.T) { tree := flatTree(t, flatNode("/a", "one two", 0, 0), flatNode("/space", " ", 0, 30), flatNode("/b", "δΈ‰", 0, 60)) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } got, moved, _ := cursor.Read(Character, -1) if moved || got.Text != "o" { t.Fatalf("start boundary: %#v %v", got, moved) } for _, text := range []string{"two", "δΈ‰"} { got, moved, err := cursor.Read(Word, 1) if err != nil || !moved || strings.TrimSpace(got.Text) != text { t.Fatalf("word: %#v %v %v", got, moved, err) } } got, moved, _ = cursor.Read(Word, 1) if moved || got.Text != "δΈ‰" { t.Fatalf("end boundary: %#v %v", got, moved) } got, moved, _ = cursor.Read(Word, -1) if !moved || got.Text != "two" { t.Fatalf("previous word: %#v %v", got, moved) } } func TestFlatClippedCharacterRunsKeepWordsAndOffsetGaps(t *testing.T) { n := flatNode("/a", "x", 0, 0) n.Text.Ranges = []atspi.TextRange{ {Start: 2, End: 3, Text: "猫", Bounds: atspi.Rectangle{X: 0, Y: 0, Width: 12, Height: 20}}, {Start: 3, End: 4, Text: "a", Bounds: atspi.Rectangle{X: 12, Y: 0, Width: 10, Height: 20}}, {Start: 7, End: 8, Text: "z", Bounds: atspi.Rectangle{X: 80, Y: 0, Width: 10, Height: 20}}, } cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { t.Fatal(err) } got, _, _ := cursor.Read(Line, 0) if got.Text != "猫a z" || got.Offset != 2 { t.Fatalf("fragmented line: %#v", got) } got, _, _ = cursor.Read(Word, 0) if got.Text != "猫a" { t.Fatalf("fragmented word: %#v", got) } got, _, _ = cursor.Read(Word, 1) if got.Text != "z" || got.Offset != 7 { t.Fatalf("offset gap lost: %#v", got) } } func TestFlatClipsSyntheticNamesAndSuppressesHiddenDescendants(t *testing.T) { parent := flatNode("/parent", "unused", 0, 0) parent.Text = atspi.VisibleText{} parent.Snapshot.Name = "Container" child := flatNode("/child", "unused", 150, 0) child.Text = atspi.VisibleText{} child.Snapshot.Name = "Button" parent.Children = []atspi.ObjectID{child.Snapshot.ID()} lines, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child}) if err != nil || len(lines) != 1 || len(lines[0].zones) != 1 || lines[0].zones[0].bounds.Width != 50 { t.Fatalf("clipping: %#v %v", lines, err) } parent.Geometry = atspi.Geometry{VisibilityKnown: true} if _, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child}); !errors.Is(err, ErrNoReviewContent) { t.Fatalf("hidden descendant: %v", err) } } func TestFlatCaretAndSyntheticCharacterPosition(t *testing.T) { n := flatNode("/a", "abπŸ™‚", 0, 0) n.Text.CaretKnown, n.Text.CaretOffset = true, 2 cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { t.Fatal(err) } got, _, _ := cursor.Read(Character, 0) if got.Text != "πŸ™‚" || got.Offset != 2 { t.Fatalf("caret: %#v", got) } cursor = &FlatCursor{} n.Text.CaretOffset = 3 if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil { t.Fatal(err) } got, _, _ = cursor.Read(Character, 0) if got.Text != "" || got.Offset != 3 { t.Fatalf("end caret: %#v", got) } got, moved, _ := cursor.Read(Character, -1) if !moved || got.Text != "πŸ™‚" { t.Fatalf("before end caret: %#v", got) } n.Text = atspi.VisibleText{} n.Snapshot.Name = "Save" tree := flatTree(t, n) cursor = &FlatCursor{} for _, want := range []string{"a", "v", "e"} { if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } got, moved, err := cursor.Read(Character, 1) if err != nil || !moved || got.Text != want { t.Fatalf("synthetic position: %#v %v", got, err) } } } func TestFlatModelBoundsSyntheticText(t *testing.T) { var nodes []atspi.TreeNode for i := 0; i < 300; i++ { n := flatNode("/a", "x", 0, int32(i*20)) n.Text = atspi.VisibleText{} n.Snapshot.Name = strings.Repeat("a", 4096) nodes = append(nodes, n) } if _, err := buildFlatLines(context.Background(), nodes); !errors.Is(err, ErrLimitedReview) { t.Fatal(err) } } func TestFlatDoesNotRestoreReusedObjectIdentity(t *testing.T) { tree := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Original", 0, 30)) cursor := &FlatCursor{} if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } cursor.Read(Line, 1) tree.Invalidate(objectID("/b")) fresh := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Replacement", 0, 30)) snapshot, err := fresh.ReviewWindow(context.Background(), objectID("/a")) if err != nil { t.Fatal(err) } if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { t.Fatal(err) } if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil { t.Fatal(err) } got, _, _ := cursor.Read(Line, 0) if got.Text != "Focus" { t.Fatalf("reused identity inherited position: %#v", got) } } func TestFlatBlankRowsAcrossReviewUnits(t *testing.T) { n := flatNode("/terminal", "a", 0, 0) n.Geometry.Bounds.Height = 80 n.Text.Ranges = []atspi.TextRange{ {Start: 0, End: 1, Text: "a", Bounds: atspi.Rectangle{Width: 20, Height: 20}}, {Start: 2, End: 3, Text: "\n", Bounds: atspi.Rectangle{Y: 20, Width: 20, Height: 20}}, {Start: 3, End: 4, Text: "b", Bounds: atspi.Rectangle{Y: 40, Width: 20, Height: 20}}, {Start: 5, End: 6, Text: "\n", Bounds: atspi.Rectangle{Y: 60, Width: 20, Height: 20}}, } tree := flatTree(t, n) cursor := &FlatCursor{} for _, tc := range []struct { unit FlatUnit step int text string offset int moved bool }{ {Line, 1, "\n", 2, true}, {Character, 0, "\n", 2, false}, {Line, 0, "\n", 2, false}, {Line, 1, "b", 3, true}, {Word, -1, "\n", 2, true}, {Word, -1, "a", 0, true}, {Word, 1, "\n", 2, true}, {Word, 1, "b", 3, true}, {WindowEnd, 0, "\n", 5, true}, {Line, 1, "\n", 5, false}, {WindowStart, 0, "a", 0, true}, {Character, 1, "\n", 2, true}, {Character, 1, "b", 3, true}, } { if err := cursor.Refresh(context.Background(), tree, n.Snapshot.ID()); err != nil { t.Fatal(err) } got, moved, err := cursor.Read(tc.unit, tc.step) if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved { t.Fatalf("%+v: got=%+v moved=%v err=%v", tc, got, moved, err) } } }