Files
cthulhu/internal/atspi/toby_combo_native_test.go

150 lines
5.0 KiB
Go

// Cthulhu screen reader.
// 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 TestNativeTobyComboPopupLifetime(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TOBY_COMBO") != "1" {
t.Skip("requires isolated Toby combo fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*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.Error(err)
}
}()
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
key := func(keys ...string) {
t.Helper()
arguments := append([]string{"key", "--delay", "120"}, keys...)
if err := exec.CommandContext(ctx, "xdotool", arguments...).Run(); err != nil {
t.Fatal(err)
}
}
var combo ObjectID
var comboCarets int
var slowest time.Duration
wait := func(description string, match func(Event) bool) Event {
t.Helper()
deadline := time.NewTimer(4 * time.Second)
defer deadline.Stop()
var recent []string
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationName != "toby_combo_fixture.py" {
continue
}
recent = append(recent, event.Type+" "+event.Source.Role+" "+event.Source.Name)
if len(recent) > 12 {
recent = recent[1:]
}
slowest = max(slowest, event.SnapshotDuration)
if event.Type == CaretEvent && event.Source.ReadOnlyCombo.Valid() {
comboCarets++
if event.Source.ReadOnlyCombo != combo || event.Source.CaretWord != "" {
t.Fatalf("read-only combo caret: owner=%v word=%q", event.Source.ReadOnlyCombo, event.Source.CaretWord)
}
}
if match(event) {
return event
}
case <-deadline.C:
t.Fatalf("missing %s; recent events: %q; background filtered=%d", description, recent, adapter.backgroundEventsFiltered.Load())
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
focus := func(role, name string) Event {
t.Helper()
return wait("focus on "+role+" "+name, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 &&
event.Source.Role == role && event.Source.Name == name
})
}
name := func(value string) Event {
t.Helper()
return wait("combo value "+value, func(event Event) bool {
return event.Type == AccessibleNameEvent && event.Source.Role == "combo box" && event.Source.Name == value
})
}
// Closed arrows produce consecutive caret events without another combo
// focus event between them. This was missing from the popup-only probe.
key("Tab", "shift+Tab")
combo = focus("combo box", "Classic Doom").Source.ID()
for _, step := range []struct{ key, value string }{
{"Down", "Bring Back the 90s"}, {"Down", "Funny Pack - Toby Doom"},
{"Up", "Bring Back the 90s"}, {"Up", "Classic Doom"},
{"End", "Funny Pack - Toby Doom"}, {"Home", "Classic Doom"},
} {
key(step.key)
name(step.value)
}
if comboCarets < 4 {
t.Fatalf("only %d read-only combo caret events observed", comboCarets)
}
for _, closeKey := range []string{"Return", "Escape"} {
key("alt+Down")
focus("list", "")
key("Down")
wait("popup selection", func(event Event) bool {
return event.Type == SelectionChangedEvent && event.Source.Role == "list item" && event.Source.Selected
})
key(closeKey)
wait("combo focus after "+closeKey, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
key("Tab")
focus("button", "Launch")
key("shift+Tab")
wait("return to combo", func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
}
key("Tab")
focus("button", "Launch")
// Other text controls still need native word capture, including read-only
// fields outside combos and writable combo editors in this same provider.
for _, control := range []struct{ role, name string }{
{"text", "Plain editor"}, {"text", "Read-only text"}, {"combo box", "alpha beta"},
} {
key("Tab")
// Qt can expose entry into a line edit only through a text-selection
// event, without refreshing libatspi's focused state or emitting focus.
wait("entry into "+control.name, func(event Event) bool {
return event.Source.Role == control.role && event.Source.Name == control.name
})
key("Home", "ctrl+Right")
caret := wait("word caret in "+control.name, func(event Event) bool {
return event.Type == CaretEvent && event.Source.CaretWord == "beta"
})
if caret.Source.ReadOnlyCombo.Valid() {
t.Fatalf("ordinary text caret acquired combo selection owner: %+v", caret.Source)
}
}
if dropped := adapter.Overflow(); dropped != 0 {
t.Fatalf("native event queue dropped %d events", dropped)
}
t.Logf("closed arrows, Home/End, popup Return/Escape and text navigation completed; slowest snapshot %s", slowest)
}