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