Merge branch 'testing' of ssh://git.stormux.org:1101/storm/cthulhu into testing

This commit is contained in:
Storm Dragon committed 2026-11-10 21:34:44 -05:00
commit 2fb4a1b9a0
6 files changed
+190 -3

No files matched your search

+60
View File
@@ -448,3 +448,63 @@ func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testi
cancel()
<-completed
}
// Replay YAD's stdin-fed menu sequence: initial unselected row, stale active
// descendant for that row on Down, then the actual new selection.
func TestListPromptInitialRowAndFirstArrow(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(completed)
}()
window := atspi.NewObjectID(":1.9", "/menu")
list := atspi.NewSnapshot("/menu/list", "", "tree table", "", "")
list.BusName = window.BusName
activation := atspi.NewEvent(atspi.WindowActivatedEvent, list)
activation.Window, activation.WindowTitle, activation.WindowMessage = window, "Action menu", "Please select an action"
adapter.events <- activation
cell := atspi.NewSnapshot("/menu/play", "Play", "table cell", "", "")
cell.BusName, cell.TablePath = window.BusName, list.Path
cell.GUITable, cell.TablePositionKnown = true, true
cell.TableRowText = "Play"
focus := atspi.NewEvent(atspi.FocusedStateEvent, cell)
focus.Window, focus.Detail1 = window, 1
adapter.events <- focus
expect := func(text string, appendSpeech bool) {
t.Helper()
select {
case request := <-speech.requests:
if request.Text != text || request.Append != appendSpeech {
t.Fatalf("got %q append=%v; want %q append=%v", request.Text, request.Append, text, appendSpeech)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", text)
}
}
expect("Action menu, Please select an action", false)
expect("Play", true)
adapter.events <- focus // Duplicate container focus must not repeat Play.
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, cell)
second := cell
second.Path, second.Name, second.TableRowText = "/menu/install", "Install", "Install"
second.TableRowIndex, second.Selected, second.Focused = 1, true, true
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
expect("Install", false)
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, second)
cell.Selected, cell.Focused = true, true
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell)
expect("Play", false)
cancel()
select {
case <-completed:
case <-time.After(time.Second):
t.Fatal("event loop did not stop")
}
if len(speech.requests) != 0 {
t.Fatal("unexpected duplicate menu speech")
}
}
@@ -50,7 +50,8 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
ownedTarget = true
}
// A table's first selected child is its active child only when the
// selection is unique. Multi-selection changes use their own events.
// selection is unique. An unselected single-column GTK list can
// present its initial row without changing selection.
targetRole := C.atspi_accessible_get_role(target, nil)
tableContainer := C.cthulhu_is_table_container(target) != 0
// GTK notebooks report container focus before the active tab when an
@@ -59,7 +60,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || tableContainer {
var child *C.AtspiAccessible
if tableContainer {
child = C.cthulhu_table_selected_child(target)
child = C.cthulhu_table_focus_child(target)
} else {
child = C.cthulhu_selected_child(target)
}
+38
View File
@@ -219,6 +219,44 @@ AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source) {
return child;
}
// GTK lists populated through stdin can have no selected row at focus time.
// Their first navigation starts at row zero. Present that initial row without
// selecting it or moving the application's cursor. Selection-change queries
// must continue to return only actual selections.
AtspiAccessible *cthulhu_table_focus_child(AtspiAccessible *source) {
AtspiAccessible *child = cthulhu_table_selected_child(source);
if (child != NULL) return child;
gchar *toolkit = atspi_accessible_get_toolkit_name(source, NULL);
gboolean gtk = g_ascii_strcasecmp(toolkit != NULL ? toolkit : "", "gtk") == 0;
g_free(toolkit);
if (!gtk) return NULL;
AtspiStateSet *states = atspi_accessible_get_state_set(source);
gboolean initial = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
!atspi_state_set_contains(states, ATSPI_STATE_MULTISELECTABLE);
if (states != NULL) g_object_unref(states);
if (!initial) return NULL;
AtspiTable *table = atspi_accessible_get_table_iface(source);
if (table == NULL) return NULL;
GError *error = NULL;
gint selected = atspi_table_get_n_selected_rows(table, &error);
if (error == NULL && selected == 0 && atspi_table_get_n_rows(table, NULL) > 0 &&
atspi_table_get_n_columns(table, NULL) == 1) {
child = atspi_table_get_accessible_at(table, 0, 0, NULL);
if (child != NULL) {
states = atspi_accessible_get_state_set(child);
gboolean showing = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!showing) { g_object_unref(child); child = NULL; }
}
}
g_clear_error(&error);
g_object_unref(table);
return child;
}
// Resolve the focused descendant at a window-activation boundary. Providers
// such as Steam's Chromium UI can activate a frame without emitting a usable
// object-focus event. Keep the synchronous callback work bounded so a broken
+1
View File
@@ -127,6 +127,7 @@ cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source)
AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event);
AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source);
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source);
AtspiAccessible *cthulhu_table_focus_child(AtspiAccessible *source);
gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source);
gboolean cthulhu_is_table_container(AtspiAccessible *source);
AtspiAccessible *cthulhu_returned_menu_item(AtspiAccessible *menu);
+87
View File
@@ -311,3 +311,90 @@ func TestNativeGTKRadioGroupName(t *testing.T) {
}
}
}
func TestNativeYADListInitialRow(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_YAD") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Errorf("stop adapter: %v", err)
}
}()
command := exec.CommandContext(ctx, "yad", "--list", "--title=Initial menu probe",
"--text=Please select an action", "--column=Option", "--no-headers",
"--selectable-labels", "--search-column=1", "--height=400", "--width=600")
command.Stdin = strings.NewReader("Play\nInstall\n")
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
_ = command.Process.Kill()
_ = command.Wait()
}()
var yadWindow string
for yadWindow == "" && ctx.Err() == nil {
output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Initial menu probe$").Output()
if err == nil {
yadWindow = strings.TrimSpace(string(output))
}
if yadWindow == "" {
time.Sleep(20 * time.Millisecond)
}
}
if yadWindow == "" {
t.Fatal("YAD list window did not appear")
}
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil {
t.Fatalf("focus YAD list window: %v: %s", err, output)
}
stage := 0
for {
select {
case <-ctx.Done():
t.Fatalf("menu event missing at navigation stage %d", stage)
case event := <-adapter.Events():
if event.Source.ApplicationName != "yad" {
continue
}
if stage == 0 {
if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Role != "table cell" || event.Source.Name != "Play" {
continue
}
if event.Source.Selected || !event.Source.GUITable || event.Source.TablePath == "" {
t.Fatalf("initial row must retain table ownership without selecting it: %#v", event.Source)
}
} else {
want := "Install"
if stage == 2 {
want = "Play"
}
if event.Type != SelectionChangedEvent || event.Source.Name != want {
continue
}
if !event.Source.Selected {
t.Fatalf("navigation did not select %s", want)
}
if stage == 2 {
return
}
}
key := "Down"
if stage == 1 {
key = "Up"
}
if output, err := exec.CommandContext(ctx, "xdotool", "key", key).CombinedOutput(); err != nil {
t.Fatalf("navigate: %v: %s", err, output)
}
stage++
}
}
}