218 lines
7.2 KiB
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))
|
|
}
|
|
}
|