Files
cthulhu/internal/atspi/review_table_native_test.go

218 lines
7.2 KiB
Go

// 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))
}
}