// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "os" "os/exec" "testing" "time" ) func TestNativeFlatReviewLargeGTKTable(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_REVIEW_TABLE") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } for _, mode := range []string{"large", "small", "empty", "horizontal", "compound"} { t.Run(mode, func(t *testing.T) { test_native_review_table(t, mode) }) } } func test_native_review_table(t *testing.T, mode string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-c", ` import gi, sys gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") from gi.repository import Gdk, GLib, Gtk mode = sys.argv[1] Gtk.Settings.get_default().set_property("gtk-enable-animations", False) window = Gtk.Window(title="Flat table review probe") window.set_default_size(400, 240) window.connect("destroy", Gtk.main_quit) store = Gtk.ListStore(str, str, str) rowCount = {"empty": 0, "small": 2, "horizontal": 20, "compound": 20}.get(mode, 6000) for index in range(rowCount): store.append(["Row %04d" % index, "Kind %04d" % index, "Detail %04d" % index]) view = Gtk.TreeView(model=store) view.get_accessible().set_name("Review table") column = Gtk.TreeViewColumn("Items", Gtk.CellRendererText(), text=0) if mode == "compound": icon = Gtk.CellRendererPixbuf() icon.set_property("icon-name", "text-x-generic") column.pack_start(icon, False) view.append_column(column) if mode == "horizontal": view.append_column(Gtk.TreeViewColumn("Kind", Gtk.CellRendererText(), text=1)) view.append_column(Gtk.TreeViewColumn("Detail", Gtk.CellRendererText(), text=2)) for column in view.get_columns(): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_fixed_width(250) scroller = Gtk.ScrolledWindow() scroller.add(view) window.add(scroller) window.show_all() def ready(): if rowCount: path = Gtk.TreePath.new_from_indices([rowCount//2]) column = view.get_columns()[-1] if mode == "horizontal" else None view.set_cursor(path, column) view.scroll_to_cell(path, column, True, 0.5, 1) window.present() view.grab_focus() acknowledge_paint("Flat table review ready") return False def acknowledge_paint(title): def painted(clock): clock.disconnect(handlerId) window.set_title(title) handlerId = window.get_frame_clock().connect("after-paint", painted) window.queue_draw() GLib.idle_add(ready) def on_key(_window, event): if event.keyval == Gdk.KEY_F6: store[4500][0] = "Updated 4500" path = Gtk.TreePath.new_from_indices([4500]) view.set_cursor(path) view.scroll_to_cell(path, None, True, 0.5, 0) acknowledge_paint("Flat table review updated") return True return False window.connect("key-press-event", on_key) Gtk.main() `, mode) fixture.Stderr = os.Stderr adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) if err := fixture.Start(); err != nil { t.Fatal(err) } defer func() { _ = fixture.Process.Kill() _ = fixture.Wait() }() if output, err := exec.CommandContext(ctx, "xdotool", "search", "--sync", "--onlyvisible", "--name", "^Flat table review (probe|ready)$", "windowfocus").CombinedOutput(); err != nil { t.Fatalf("focus fixture: %v: %s", err, output) } var focus ObjectID painted := false for !focus.Valid() || !painted { select { case event := <-adapter.Events(): painted = painted || event.Source.Name == "Flat table review ready" || event.WindowTitle == "Flat table review ready" if (event.Source.Role == "table cell" || event.Source.Name == "Review table") && (event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) { focus = event.Source.ID() } case <-ctx.Done(): t.Fatal("table focus missing") } } captureCtx, captureCancel := context.WithTimeout(ctx, 5*time.Second) defer captureCancel() tree, err := adapter.CaptureReviewWindow(captureCtx, focus) if err != nil { t.Fatal(err) } snapshot, err := tree.ReviewWindow(ctx, focus) if err != nil { t.Fatal(err) } want := "Row 3000" switch mode { case "small": want = "Row 0001" case "empty": want = "Items" case "horizontal": want = "Detail 0010" case "compound": want = "Row 0010" } found := false headerFound := false visibleNames := make(map[string]bool) for _, node := range snapshot.Nodes { if node.Geometry.Visible && node.Geometry.Showing && node.Geometry.BoundsKnown { visibleNames[node.Snapshot.Name] = true } if node.Snapshot.Name == want && node.Geometry.Visible && node.Geometry.Showing && node.Geometry.BoundsKnown { found = true } if node.Snapshot.Role == "table column header" { headerFound = true } if mode == "horizontal" && node.Snapshot.Name == "Row 0010" && len(node.Text.Ranges) != 0 { t.Fatal("captured text from horizontally hidden focus anchor") } } if !found { for _, node := range snapshot.Nodes { t.Logf("node: %q %q geometry=%+v", node.Snapshot.Name, node.Snapshot.Role, node.Geometry) } t.Fatalf("visible content %q missing", want) } if !headerFound { t.Fatal("visible column header missing") } if mode == "large" && (!visibleNames["Row 2999"] || !visibleNames["Row 3001"]) { t.Fatal("visible rows adjacent to focus are missing") } t.Logf("captured %d viewport objects", len(snapshot.Nodes)) if len(snapshot.Nodes) > 100 { t.Fatalf("captured %d nodes for a small table viewport", len(snapshot.Nodes)) } if mode != "large" { return } if output, err := exec.CommandContext(ctx, "xdotool", "key", "F6").CombinedOutput(); err != nil { t.Fatalf("scroll fixture: %v: %s", err, output) } updated, painted := false, false for !updated || !painted { select { case event := <-adapter.Events(): painted = painted || event.Source.Name == "Flat table review updated" || event.WindowTitle == "Flat table review updated" if event.Source.Name == "Updated 4500" && (event.Type == ActiveDescendantEvent || event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) { focus = event.Source.ID() updated = true } case <-ctx.Done(): t.Fatal("updated table focus missing") } } tree, err = adapter.CaptureReviewWindow(ctx, focus) if err != nil { t.Fatal(err) } snapshot, err = tree.ReviewWindow(ctx, focus) if err != nil { t.Fatal(err) } found = false visibleNames = make(map[string]bool) for _, node := range snapshot.Nodes { if node.Geometry.Visible && node.Geometry.Showing && node.Geometry.BoundsKnown { visibleNames[node.Snapshot.Name] = true } if node.Snapshot.Name == "Updated 4500" && node.Geometry.Visible && node.Geometry.Showing && node.Geometry.BoundsKnown { found = true } if node.Snapshot.Name == "Row 3000" && len(node.Text.Ranges) > 0 { t.Fatal("recapture retained the previous viewport's text") } } if !found || !visibleNames["Row 4499"] || !visibleNames["Row 4501"] || len(snapshot.Nodes) > 100 { t.Fatalf("updated viewport: found=%t nodes=%d", found, len(snapshot.Nodes)) } }