// 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" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestTableNavigationSpansHeadersAndBoundaries(t *testing.T) { node := func(path, role, name string, children ...string) atspi.TreeNode { s := atspi.NewSnapshot(path, name, role, "", "") s.BusName = webID(path).BusName n := atspi.TreeNode{Snapshot: s} for _, child := range children { n.Children = append(n.Children, webID(child)) } return n } nodes := []atspi.TreeNode{node("/doc", "document web", "", "/table", "/outside"), node("/table", "table", "", "/a", "/b", "/c", "/d", "/e"), node("/outside", "paragraph", "Outside")} nodes[1].Snapshot.TableRowCount, nodes[1].Snapshot.TableColumnCount = 3, 2 for _, c := range []struct { path, name string row, col, rs, cs int }{{"/a", "A", 0, 0, 1, 1}, {"/b", "B", 0, 1, 1, 1}, {"/c", "Merged", 1, 0, 1, 2}, {"/d", "D", 2, 0, 1, 1}, {"/e", "", 2, 1, 1, 1}} { n := node(c.path, "table cell", c.name) n.Snapshot.TablePositionKnown = true n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex = c.row, c.col n.Snapshot.TableRowSpan, n.Snapshot.TableColumnSpan = c.rs, c.cs if c.col == 1 { n.Snapshot.TableColumnHeaders = "Second" } nodes = append(nodes, n) } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) cursor.SyncFocus(context.Background(), webID("/table")) for _, step := range []struct { command DocumentCaretCommand path, text string moved bool }{ {TableFirst, "/a", "Row 1, column 1.", true}, {TableRight, "/b", "Second B", true}, {TableDown, "/c", "Spans 2 columns.", true}, {TableDown, "/e", "blank Row 3, column 2.", true}, {TableUp, "/c", "Merged", true}, {TableUp, "/b", "B", true}, {TableUp, "/b", "Top of column.", false}, {TableRight, "/b", "End of row.", false}, {TableLast, "/e", "blank", true}, {TableDown, "/e", "Bottom of column.", false}, {TableFirst, "/a", "A", true}, {TableLeft, "/a", "Beginning of row.", false}, } { got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.command) if err != nil || got.Moved != step.moved || got.Position.Object.ID() != webID(step.path) || !strings.Contains(got.Text, step.text) { t.Fatalf("command %v: %+v, %v", step.command, got, err) } } cursor.SyncFocus(context.Background(), webID("/outside")) got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, TableRight) if err != nil || got.Moved || got.Text != "Not in a table." { t.Fatalf("outside: %+v %v", got, err) } } func TestTableNavigationRowSpanAndNestedTable(t *testing.T) { makeNode := func(path, role string, children ...string) atspi.TreeNode { s := atspi.NewSnapshot(path, path, role, "", "") s.BusName = webID(path).BusName n := atspi.TreeNode{Snapshot: s} for _, child := range children { n.Children = append(n.Children, webID(child)) } return n } doc := makeNode("/doc", "document web", "/table") table := makeNode("/table", "table", "/a", "/b", "/c") table.Snapshot.TableRowCount, table.Snapshot.TableColumnCount = 2, 2 a := makeNode("/a", "table cell", "/nested") b := makeNode("/b", "table cell") c := makeNode("/c", "table cell") for _, n := range []*atspi.TreeNode{&a, &b, &c} { n.Snapshot.TablePositionKnown = true n.Snapshot.TableRowSpan = 1 n.Snapshot.TableColumnSpan = 1 } a.Snapshot.TableRowSpan = 2 b.Snapshot.TableColumnIndex = 1 c.Snapshot.TableRowIndex = 1 c.Snapshot.TableColumnIndex = 1 nested := makeNode("/nested", "table", "/inner") nested.Snapshot.TableRowCount = 1 nested.Snapshot.TableColumnCount = 1 inner := makeNode("/inner", "table cell") inner.Snapshot.TablePositionKnown = true inner.Snapshot.TableRowSpan = 1 inner.Snapshot.TableColumnSpan = 1 tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{doc, table, a, b, c, nested, inner}) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) cursor.SyncFocus(context.Background(), webID("/c")) for _, step := range []struct { cmd DocumentCaretCommand path string }{{TableLeft, "/a"}, {TableRight, "/c"}, {TableUp, "/b"}, {TableLeft, "/a"}, {TableRight, "/b"}} { got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.cmd) if err != nil || got.Position.Object.ID() != webID(step.path) { t.Fatalf("%v: %+v %v", step.cmd, got, err) } } cursor.SyncFocus(context.Background(), webID("/inner")) got, err := cursor.MoveCaret(context.Background(), caretTextReader{}, TableRight) if err != nil || got.Moved || got.Text != "End of row." { t.Fatalf("nested table escaped: %+v %v", got, err) } } func TestTableNavigationSkipsHolesAndKeepsLocalDependencies(t *testing.T) { makeNode := func(path, role string, children ...string) atspi.TreeNode { s := atspi.NewSnapshot(path, path, role, "", "") s.BusName = webID(path).BusName n := atspi.TreeNode{Snapshot: s} for _, child := range children { n.Children = append(n.Children, webID(child)) } return n } nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/table", "/ticker"), makeNode("/table", "table", "/a", "/b", "/c"), makeNode("/ticker", "static")} nodes[1].Snapshot.TableRowCount, nodes[1].Snapshot.TableColumnCount = 1000000, 3 for _, cell := range []struct { path string row, col, span int }{{"/a", 0, 0, 1000000}, {"/b", 1, 1, 1}, {"/c", 999999, 1, 1}} { n := makeNode(cell.path, "table cell") n.Snapshot.TablePositionKnown = true n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex, n.Snapshot.TableRowSpan = cell.row, cell.col, cell.span nodes = append(nodes, n) } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) if _, err = cursor.SyncFocus(context.Background(), webID("/a")); err != nil { t.Fatal(err) } tree.InvalidateText(webID("/ticker")) for _, step := range []struct { command DocumentCaretCommand path string }{{TableRight, "/b"}, {TableDown, "/c"}, {TableUp, "/b"}, {TableLeft, "/a"}, {TableRight, "/b"}} { move, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.command) if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) { t.Fatalf("%s: %+v error=%v", step.command, move, err) } } // Changed cells inside the table must still refresh the coordinate/header view. tree.InvalidateSnapshot(webID("/c")) before := cursor.places[0] if _, err = cursor.MoveCaret(context.Background(), caretTextReader{}, TableDown); !errors.Is(err, atspi.ErrStaleSnapshot) { t.Fatalf("stale cell ignored: %v", err) } if after := cursor.places[0]; after.object != before.object || after.tableRow != before.tableRow { t.Fatal("failed move changed table position") } }