Sync with testing.
This commit is contained in:
commit
f2a35a9305
14 files changed
+502
-6
No files matched your search
@@ -44,7 +44,7 @@ Expose GUI controls through accessibility APIs. Keep outer preferences combo wid
|
|||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately.
|
Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. Specify a 24-bit Xvfb screen for Qt fixtures: Slint's `xvfb-run` defaults to 8-bit, which can crash Qt text-cursor painting. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately.
|
||||||
|
|
||||||
For performance work, run the relevant `BenchmarkBehavior` functions before and after changes using repeated runs and allocation reporting. Add or expand representative benchmarks when investigating a new bottleneck; keep fixture construction outside timed loops and check that the intended work actually happens. Preserve announcement content, ordering, interruption, focus, and Braille behavior. Benchmark timing alone does not establish a bug or live responsiveness, and noisy timing changes must not become automatic pass/fail thresholds. See [benchmark procedures](docs/testing/benchmarks.md) for commands and measurement limits. Plain `go test` does not run benchmarks.
|
For performance work, run the relevant `BenchmarkBehavior` functions before and after changes using repeated runs and allocation reporting. Add or expand representative benchmarks when investigating a new bottleneck; keep fixture construction outside timed loops and check that the intended work actually happens. Preserve announcement content, ordering, interruption, focus, and Braille behavior. Benchmark timing alone does not establish a bug or live responsiveness, and noisy timing changes must not become automatic pass/fail thresholds. See [benchmark procedures](docs/testing/benchmarks.md) for commands and measurement limits. Plain `go test` does not run benchmarks.
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,52 @@ go test ./internal/atspi ./internal/presentation ./internal/app -count=1
|
|||||||
CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py
|
CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Editable combos with read-only editors
|
||||||
|
|
||||||
|
Some Qt applications make a combo editable, then make its internal line edit
|
||||||
|
read-only. Qt can expose that child with both EDITABLE and READ_ONLY states.
|
||||||
|
Closed Up/Down navigation sends child caret events followed by a name/value
|
||||||
|
event from the combo. A caret event can arrive without a new focus event.
|
||||||
|
|
||||||
|
On Slint with Qt/PySide6 6.9.3 and AT-SPI 2.58.1, querying the word at that
|
||||||
|
child's caret reproduced an 800 ms timeout, followed by stale list-item
|
||||||
|
queries and loss of further accessibility events. Cthulhu identifies these
|
||||||
|
children using READ_ONLY plus a direct combo parent, copies that parent's
|
||||||
|
identity into each caret snapshot, and omits the unused word query. It keeps
|
||||||
|
native focus on the combo and lets its name/value event present the selection.
|
||||||
|
This does not depend on the last caret/focus event or on a coalescing delay.
|
||||||
|
Writable combo editors and standalone read-only text fields retain word capture.
|
||||||
|
|
||||||
|
The app event loop suppresses a child caret only when its copied combo identity
|
||||||
|
matches the owned focus. Normal foreground checks still apply. Popup selection
|
||||||
|
followed by focus is presented once, and deselection of the previous managed
|
||||||
|
item does not replace the current item. Deselection of the current focused
|
||||||
|
item remains audible.
|
||||||
|
|
||||||
|
Run the isolated regression with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/native_toby_combo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires PySide6, Xvfb, xdotool and the native Go build dependencies. It
|
||||||
|
explicitly uses a 24-bit screen; Slint's 8-bit Xvfb default can crash Qt's text
|
||||||
|
cursor painter before accessibility checks complete. Fixture diagnostics are
|
||||||
|
saved in the printed `/tmp/cthulhu-combo-qt-*.log` path. The test
|
||||||
|
checks consecutive closed arrows, Home/End, popup Return and Escape,
|
||||||
|
Tab/Shift+Tab after each close, and word capture in writable fields, standalone
|
||||||
|
read-only fields and writable combo editors. It waits for each provider event
|
||||||
|
instead of filling the adapter queue with an unobserved key sequence. Snapshot
|
||||||
|
timings are reported for diagnosis, without a machine-speed pass threshold.
|
||||||
|
|
||||||
|
For live acceptance, repeat those keys in Toby Doom Launcher for both IWAD and
|
||||||
|
game choices. Confirm each new value is spoken once, navigation continues after
|
||||||
|
several changes, and Tab/Shift+Tab still reaches narration and buttons. In an
|
||||||
|
ordinary text field and writable combo, check typing and Ctrl+Left/Right word
|
||||||
|
reading. Switch away with a popup open and confirm background events do not
|
||||||
|
interrupt the new foreground application. Check a physical Braille display when
|
||||||
|
available; private event tests do not establish audible or physical output.
|
||||||
|
|
||||||
## Providers which omit checkability
|
## Providers which omit checkability
|
||||||
|
|
||||||
The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self
|
The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
|||||||
"snapshot_duration", event.SnapshotDuration,
|
"snapshot_duration", event.SnapshotDuration,
|
||||||
"editable", event.Source.Editable,
|
"editable", event.Source.Editable,
|
||||||
"editable_root", event.Source.EditableRoot.Path,
|
"editable_root", event.Source.EditableRoot.Path,
|
||||||
|
"read_only_combo", event.Source.ReadOnlyCombo.Path,
|
||||||
"autocomplete", event.Source.Autocomplete,
|
"autocomplete", event.Source.Autocomplete,
|
||||||
"autocomplete_popup", event.Source.AutocompletePopup,
|
"autocomplete_popup", event.Source.AutocompletePopup,
|
||||||
"event_detail1", event.Detail1,
|
"event_detail1", event.Detail1,
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
|
|||||||
objectReview := handler.objectReview
|
objectReview := handler.objectReview
|
||||||
debounce := handler.debounce
|
debounce := handler.debounce
|
||||||
if event.Type == atspi.CaretEvent {
|
if event.Type == atspi.CaretEvent {
|
||||||
|
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
|
||||||
|
state.current.ID() == state.focusedID && state.current.Role == "combo box" {
|
||||||
|
// The combo's name/value event presents this selection, even when
|
||||||
|
// provider requests delay it past the normal caret coalescing time.
|
||||||
|
logger.Debug("skipped read-only combo editor caret", "combo_path", event.Source.ReadOnlyCombo.Path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if event.Source.ID() == state.selectionCaretID {
|
if event.Source.ID() == state.selectionCaretID {
|
||||||
state.selectionCaretID = atspi.ObjectID{}
|
state.selectionCaretID = atspi.ObjectID{}
|
||||||
if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond {
|
if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond {
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) {
|
func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) {
|
||||||
@@ -101,6 +103,59 @@ func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) {
|
|||||||
<-completed
|
<-completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessEventsReadOnlyComboUsesNameInsteadOfEditorCaret(t *testing.T) {
|
||||||
|
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||||
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
|
||||||
|
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box", Name: "Classic Doom", Focused: true}
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, combo)
|
||||||
|
editor := atspi.Snapshot{BusName: combo.BusName, Path: "/combo/editor", Role: "text", Text: "Funny Pack - Classic Doom",
|
||||||
|
BrailleLine: "Funny Pack - Classic Doom", TextCaretKnown: true, Editable: true, ReadOnly: true,
|
||||||
|
Focused: true, ReadOnlyCombo: combo.ID()}
|
||||||
|
for _, value := range []string{"Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} {
|
||||||
|
editor.Text, editor.BrailleLine = value, value
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, editor)
|
||||||
|
combo.Name = value
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, combo)
|
||||||
|
}
|
||||||
|
close(adapter.events)
|
||||||
|
processEvents(context.Background(), slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||||
|
close(speech.spoken)
|
||||||
|
var got []string
|
||||||
|
for text := range speech.spoken {
|
||||||
|
got = append(got, text)
|
||||||
|
}
|
||||||
|
want := []string{"Classic Doom, combo box", "Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("combo speech = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadOnlyComboCaretRequiresExactOwnedCombo(t *testing.T) {
|
||||||
|
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box"}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
owner atspi.ObjectID
|
||||||
|
skip bool
|
||||||
|
}{
|
||||||
|
{"owned", combo.ID(), true},
|
||||||
|
{"different combo", atspi.NewObjectID(combo.BusName, "/other-combo"), false},
|
||||||
|
{"different provider", atspi.NewObjectID(":1.8", combo.Path), false},
|
||||||
|
{"ordinary editor", atspi.ObjectID{}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
state := &eventLoopState{}
|
||||||
|
state.current, state.focusedID = combo, combo.ID()
|
||||||
|
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), debounce: time.Hour}
|
||||||
|
event := atspi.NewEvent(atspi.CaretEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/editor", Role: "text", ReadOnlyCombo: test.owner})
|
||||||
|
handler.handleCaret(event, input.Activity{}, input.NoIntent)
|
||||||
|
defer state.pending.clear()
|
||||||
|
if skipped := state.pending.caret.event.Type == ""; skipped != test.skip {
|
||||||
|
t.Fatalf("caret skipped = %v, want %v", skipped, test.skip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
|
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
|
||||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
|||||||
// Compare with the owned focus snapshot, not the last speech request:
|
// Compare with the owned focus snapshot, not the last speech request:
|
||||||
// a state/value update can intervene between two toolkit focus events.
|
// a state/value update can intervene between two toolkit focus events.
|
||||||
// Still refresh review and braille below before suppressing presentation.
|
// Still refresh review and braille below before suppressing presentation.
|
||||||
|
managedSelectionFocus := isFocusGainEvent(event) && state.managedDescendantID.Valid() &&
|
||||||
|
event.Source.ID() == state.managedDescendantID && sameSelectionItem(event.Source, state.current)
|
||||||
duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) &&
|
duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) &&
|
||||||
event.Source.Role != "terminal" && !presentsReusedRow(event.Source) &&
|
event.Source.Role != "terminal" && !presentsReusedRow(event.Source) &&
|
||||||
!(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) &&
|
!(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) &&
|
||||||
state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
|
((state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
|
||||||
event.Source.ID() == state.current.ID() &&
|
event.Source.ID() == state.current.ID()) || managedSelectionFocus) &&
|
||||||
presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) &&
|
presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) &&
|
||||||
slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions))
|
slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions))
|
||||||
if isFocusEvent(event.Type) {
|
if isFocusEvent(event.Type) {
|
||||||
@@ -93,6 +95,11 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
|||||||
logger.Debug("skipped selection already presented by focus", "role", event.Source.Role)
|
logger.Debug("skipped selection already presented by focus", "role", event.Source.Role)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
} else if event.Type == atspi.SelectedStateEvent && !event.Source.Selected &&
|
||||||
|
(event.Source.Role == "tree item" || event.Source.Role == "list item") &&
|
||||||
|
state.managedDescendantID.Valid() && event.Source.ID() != state.managedDescendantID {
|
||||||
|
logger.Debug("skipped deselected previous managed descendant", "role", event.Source.Role)
|
||||||
|
return true
|
||||||
} else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
|
} else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
|
||||||
(state.focusedID.Path == "" || event.Source.ID() != state.focusedID) {
|
(state.focusedID.Path == "" || event.Source.ID() != state.focusedID) {
|
||||||
logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role)
|
logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role)
|
||||||
|
|||||||
@@ -67,6 +67,52 @@ func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessEventsSuppressesQtPopupSelectionRepeats(t *testing.T) {
|
||||||
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||||
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
defer func() { cancel(); <-done }()
|
||||||
|
|
||||||
|
list := atspi.Snapshot{BusName: ":1.7", Path: "/combo/list", Role: "list"}
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, list)
|
||||||
|
if got := <-speech.spoken; got != "list" {
|
||||||
|
t.Fatalf("list focus speech = %q", got)
|
||||||
|
}
|
||||||
|
item := func(path, name string) atspi.Snapshot {
|
||||||
|
return atspi.Snapshot{BusName: list.BusName, Path: path, Role: "list item", Name: name, Focused: true, Selected: true}
|
||||||
|
}
|
||||||
|
first := item("/combo/list/1", "Classic Doom")
|
||||||
|
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, first)
|
||||||
|
if got := <-speech.spoken; got != "Classic Doom, list item, selected" {
|
||||||
|
t.Fatalf("first selection speech = %q", got)
|
||||||
|
}
|
||||||
|
firstFocus := atspi.NewEvent(atspi.FocusedStateEvent, first)
|
||||||
|
firstFocus.Detail1 = 1
|
||||||
|
adapter.events <- firstFocus
|
||||||
|
|
||||||
|
second := item("/combo/list/2", "Funny Pack - Toby Doom")
|
||||||
|
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
|
||||||
|
if got := <-speech.spoken; got != "Funny Pack - Toby Doom, list item, selected" {
|
||||||
|
t.Fatalf("second selection speech = %q", got)
|
||||||
|
}
|
||||||
|
first.Focused, first.Selected = false, false
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, first)
|
||||||
|
secondFocus := atspi.NewEvent(atspi.FocusedStateEvent, second)
|
||||||
|
secondFocus.Detail1 = 1
|
||||||
|
adapter.events <- secondFocus
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-speech.spoken:
|
||||||
|
t.Fatalf("Qt popup selection repeated as %q", got)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
|
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
|
||||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
|
|||||||
captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED
|
captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED
|
||||||
}
|
}
|
||||||
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
|
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
|
||||||
|
if eventType == CaretEvent {
|
||||||
|
if combo := C.cthulhu_read_only_combo(target); combo != nil {
|
||||||
|
snapshot.ReadOnlyCombo = nativeObjectID(combo)
|
||||||
|
C.g_object_unref(C.gpointer(combo))
|
||||||
|
}
|
||||||
|
}
|
||||||
if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
|
if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
|
||||||
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
|
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
|
||||||
if editor := C.cthulhu_editable_root(target); editor != nil {
|
if editor := C.cthulhu_editable_root(target); editor != nil {
|
||||||
@@ -212,7 +218,10 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if eventType == CaretEvent && snapshot.Role != "password text" {
|
// A read-only combo child only mirrors the chosen value. Qt 6.9 can stop
|
||||||
|
// answering accessibility requests after its word-at-caret query; no word
|
||||||
|
// feedback is needed for this non-writable selection proxy.
|
||||||
|
if eventType == CaretEvent && snapshot.Role != "password text" && !snapshot.ReadOnlyCombo.Valid() {
|
||||||
word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD)
|
word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD)
|
||||||
snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word))
|
snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word))
|
||||||
C.g_free(C.gpointer(word))
|
C.g_free(C.gpointer(word))
|
||||||
@@ -559,7 +568,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
focusEvent.Window = originID
|
focusEvent.Window = originID
|
||||||
}
|
}
|
||||||
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
|
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
|
||||||
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) &&
|
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) && !snapshot.ReadOnlyCombo.Valid() &&
|
||||||
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
|
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
|
||||||
adapter.rememberNativeFocus(target, snapshot.ID())
|
adapter.rememberNativeFocus(target, snapshot.ID())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -746,6 +746,28 @@ AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qt exposes an editable combo's read-only line edit as TEXT with EDITABLE
|
||||||
|
// still set. Require READ_ONLY and a direct combo parent; an unrelated editor
|
||||||
|
// in the same application must keep ordinary caret and word navigation.
|
||||||
|
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source) {
|
||||||
|
if (source == NULL) return NULL;
|
||||||
|
AtspiRole role = atspi_accessible_get_role(source, NULL);
|
||||||
|
if (role != ATSPI_ROLE_TEXT && role != ATSPI_ROLE_ENTRY) return NULL;
|
||||||
|
AtspiStateSet *states = atspi_accessible_get_state_set(source);
|
||||||
|
gboolean readOnly = states != NULL &&
|
||||||
|
atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) &&
|
||||||
|
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
||||||
|
if (states != NULL) g_object_unref(states);
|
||||||
|
if (!readOnly) return NULL;
|
||||||
|
GError *error = NULL;
|
||||||
|
AtspiAccessible *parent = atspi_accessible_get_parent(source, &error);
|
||||||
|
if (error == NULL && parent != NULL &&
|
||||||
|
atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_COMBO_BOX) return parent;
|
||||||
|
g_clear_error(&error);
|
||||||
|
if (parent != NULL) g_object_unref(parent);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
// Chromium can detach an existing document from its frame's AT-SPI parent
|
// Chromium can detach an existing document from its frame's AT-SPI parent
|
||||||
// chain while returning to a tab. Its document identity remains usable.
|
// chain while returning to a tab. Its document identity remains usable.
|
||||||
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) {
|
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
|
|||||||
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
|
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
|
||||||
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
|
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
|
||||||
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
|
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
|
||||||
|
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source);
|
||||||
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
|
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
|
||||||
gboolean cthulhu_request_event_loop_quit(void);
|
gboolean cthulhu_request_event_loop_quit(void);
|
||||||
gboolean cthulhu_request_tree_capture(gpointer userData);
|
gboolean cthulhu_request_tree_capture(gpointer userData);
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ type Snapshot struct {
|
|||||||
Editable bool
|
Editable bool
|
||||||
// EditableRoot identifies the nearest focusable writable editor or editable
|
// EditableRoot identifies the nearest focusable writable editor or editable
|
||||||
// web document. Paragraph events can belong to its native focus.
|
// web document. Paragraph events can belong to its native focus.
|
||||||
EditableRoot ObjectID
|
EditableRoot ObjectID
|
||||||
|
// ReadOnlyCombo identifies the direct combo parent of a read-only text
|
||||||
|
// child. Its caret changes mirror selection; the combo owns presentation.
|
||||||
|
ReadOnlyCombo ObjectID
|
||||||
Expandable bool
|
Expandable bool
|
||||||
Expanded bool
|
Expanded bool
|
||||||
Indeterminate bool
|
Indeterminate bool
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Storm Dragon
|
||||||
|
# Authors: Storm Dragon, Angus Kola
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Exercise Toby's editable read-only combo under a private AT-SPI session."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import site
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
from native_test_session import run_private_session
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_window():
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = subprocess.run(
|
||||||
|
["xdotool", "search", "--onlyvisible", "--name", "^Toby combo probe$"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
return result.stdout.strip().splitlines()[0]
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise RuntimeError("Toby combo fixture window missing")
|
||||||
|
|
||||||
|
|
||||||
|
def run_inside():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
diagnostics = tempfile.NamedTemporaryFile(prefix="cthulhu-combo-qt-", suffix=".log", delete=False)
|
||||||
|
print("Qt fixture diagnostics:", diagnostics.name, flush=True)
|
||||||
|
fixture = subprocess.Popen(
|
||||||
|
[sys.executable, root / "tests" / "toby_combo_fixture.py"],
|
||||||
|
stderr=diagnostics,
|
||||||
|
stdout=diagnostics,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
window = wait_for_window()
|
||||||
|
subprocess.run(["xdotool", "windowfocus", "--sync", window], check=True)
|
||||||
|
from native_x11 import set_active_window
|
||||||
|
set_active_window(int(window))
|
||||||
|
subprocess.run(
|
||||||
|
["go", "test", "./internal/atspi", "-run", "^TestNativeTobyComboPopupLifetime$",
|
||||||
|
"-count=1", "-v", "-timeout=45s"],
|
||||||
|
cwd=root, check=True, timeout=60,
|
||||||
|
env=dict(os.environ, CTHULHU_NATIVE_TOBY_COMBO="1", GOCACHE="/tmp/cthulhu-cache"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
exited = fixture.poll() is not None
|
||||||
|
if not exited:
|
||||||
|
fixture.terminate()
|
||||||
|
fixture.wait(timeout=5)
|
||||||
|
diagnostics.close()
|
||||||
|
if exited:
|
||||||
|
raise RuntimeError(f"Qt fixture exited with status {fixture.returncode}; see {diagnostics.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--inside" in sys.argv:
|
||||||
|
run_inside()
|
||||||
|
else:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="cthulhu-toby-combo-") as temporary:
|
||||||
|
environment = dict(
|
||||||
|
os.environ,
|
||||||
|
HOME=temporary,
|
||||||
|
XDG_CONFIG_HOME=temporary,
|
||||||
|
XDG_CACHE_HOME=temporary,
|
||||||
|
XDG_DATA_HOME=temporary,
|
||||||
|
QT_QPA_PLATFORM="xcb",
|
||||||
|
QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1",
|
||||||
|
NO_AT_BRIDGE="0",
|
||||||
|
PYTHONPATH=site.getusersitepackages(),
|
||||||
|
GOMODCACHE=subprocess.check_output(
|
||||||
|
["go", "env", "GOMODCACHE"], text=True
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
environment.pop("AT_SPI_BUS_ADDRESS", None)
|
||||||
|
run_private_session(
|
||||||
|
["xvfb-run", "-a", "-s", "-screen 0 1024x768x24", "dbus-run-session", "--", sys.executable,
|
||||||
|
__file__, "--inside"],
|
||||||
|
env=environment, check=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Storm Dragon
|
||||||
|
# Authors: Storm Dragon, Angus Kola
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Minimal Toby Doom Launcher combo-box fixture for native diagnostics."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QApplication, QComboBox, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class AccessibleComboBox(QComboBox):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setEditable(True)
|
||||||
|
self.lineEdit().setReadOnly(True)
|
||||||
|
self.pageStep = 5
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
current_index = self.currentIndex()
|
||||||
|
item_count = self.count()
|
||||||
|
if event.key() == Qt.Key_PageUp:
|
||||||
|
self.setCurrentIndex(max(0, current_index - self.pageStep))
|
||||||
|
elif event.key() == Qt.Key_PageDown:
|
||||||
|
self.setCurrentIndex(min(item_count - 1, current_index + self.pageStep))
|
||||||
|
elif event.key() == Qt.Key_Home:
|
||||||
|
self.setCurrentIndex(0)
|
||||||
|
self.setFocus()
|
||||||
|
self.currentIndexChanged.emit(0)
|
||||||
|
self.activated.emit(0)
|
||||||
|
elif event.key() == Qt.Key_End:
|
||||||
|
last_index = item_count - 1
|
||||||
|
self.setCurrentIndex(last_index)
|
||||||
|
self.setFocus()
|
||||||
|
self.currentIndexChanged.emit(last_index)
|
||||||
|
self.activated.emit(last_index)
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
application = QApplication([])
|
||||||
|
window = QWidget()
|
||||||
|
window.setWindowTitle("Toby combo probe")
|
||||||
|
layout = QVBoxLayout(window)
|
||||||
|
label = QLabel("Game")
|
||||||
|
combo = AccessibleComboBox()
|
||||||
|
combo.setAccessibleName("Game")
|
||||||
|
combo.addItems(("Classic Doom", "Bring Back the 90s", "Funny Pack - Toby Doom"))
|
||||||
|
label.setBuddy(combo)
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(combo)
|
||||||
|
button = QPushButton("Launch")
|
||||||
|
layout.addWidget(button)
|
||||||
|
editor = QLineEdit("alpha beta")
|
||||||
|
editor.setAccessibleName("Plain editor")
|
||||||
|
layout.addWidget(editor)
|
||||||
|
read_only = QLineEdit("alpha beta")
|
||||||
|
read_only.setReadOnly(True)
|
||||||
|
read_only.setAccessibleName("Read-only text")
|
||||||
|
layout.addWidget(read_only)
|
||||||
|
writable_combo = QComboBox()
|
||||||
|
writable_combo.setEditable(True)
|
||||||
|
writable_combo.addItems(("alpha beta", "gamma delta"))
|
||||||
|
layout.addWidget(writable_combo)
|
||||||
|
window.resize(420, 300)
|
||||||
|
window.show()
|
||||||
|
combo.setFocus()
|
||||||
|
application.exec()
|
||||||
Reference in new issue
Block a user