150 lines
5.0 KiB
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)
|
|
}
|