Fix Thunderbird deselection and review test synchronization
This commit is contained in:
1 parent
e7d02b1f62
commit
7752c4e566
4 files changed
+115
-20
No files matched your search
@@ -106,10 +106,42 @@ with 60 workflow checks in each format.
|
|||||||
The tests disable Braille; the event-loop tests separately check paragraph
|
The tests disable Braille; the event-loop tests separately check paragraph
|
||||||
Braille cursor and routing metadata.
|
Braille cursor and routing metadata.
|
||||||
|
|
||||||
|
### Regression review against 2026.10.31
|
||||||
|
|
||||||
|
The November review reproduced missing attachment deselection speech twice on
|
||||||
|
`testing` at `e7d02b1`. The same local-mail harness passed both composition
|
||||||
|
formats against `2026.10.31`. After adding an attachment to an unsent message,
|
||||||
|
Escape cleared its native selection but the current reader omitted "not
|
||||||
|
selected". A focus event could capture the already-cleared selection before
|
||||||
|
the selected-state event arrived. The shared duplicate filter discarded that
|
||||||
|
state event even though row focus announces only positive selection.
|
||||||
|
|
||||||
|
The filter now suppresses only positive selected-state announcements already
|
||||||
|
covered by row focus. Deselection remains audible when focus arrives first;
|
||||||
|
the existing rejection of deselection from the previous managed row remains.
|
||||||
|
This fixes the observed current-build failure without application matching or
|
||||||
|
new retained state. The underlying duplicate check predates the tag; this
|
||||||
|
comparison establishes the workflow regression, not its introducing commit.
|
||||||
|
|
||||||
|
`TestProcessEventsPresentsDeselectionAfterRowFocus` replays that event order for
|
||||||
|
list and tree items, including duplicate focus and positive selection events.
|
||||||
|
It fails before the fix. The rebuilt reader passes the complete HTML and
|
||||||
|
plain-text local-mail workflows and the Firefox/Thunderbird reading baseline.
|
||||||
|
These isolated Xorg checks record speech requests and native focus/selection;
|
||||||
|
audible output and physical Braille still require the live checks below.
|
||||||
|
GTK/Qt ownership checks, focused app race checks, vet and the pre-push speech
|
||||||
|
checks also pass. The full app race suite passed in 233 seconds with a ten-minute
|
||||||
|
allowance; its Tentacle package fixtures exceed the former two-minute suite
|
||||||
|
limit. AT-SPI and presentation also passed. A separate flaky review test could
|
||||||
|
wait forever for a capture after a pending focus update repaired its cursor.
|
||||||
|
That test now establishes stale state before starting the move and bounds its
|
||||||
|
capture/result waits; 100 race-detector repetitions and the full review package
|
||||||
|
pass. This changes test synchronization, not runtime cursor behavior.
|
||||||
|
|
||||||
Run the shared regression checks as well:
|
Run the shared regression checks as well:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test -race ./internal/atspi ./internal/app ./internal/review ./internal/presentation -count=1 -timeout=120s
|
go test -race ./internal/atspi ./internal/app ./internal/review ./internal/presentation -count=1 -timeout=10m
|
||||||
go vet ./internal/atspi ./internal/app ./internal/review ./internal/presentation
|
go vet ./internal/atspi ./internal/app ./internal/review ./internal/presentation
|
||||||
python3 tests/native_firefox.py
|
python3 tests/native_firefox.py
|
||||||
python3 tests/check_speech.py
|
python3 tests/check_speech.py
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
|||||||
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)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if event.Type == atspi.SelectedStateEvent && sameSelectionItem(event.Source, state.current) {
|
// Row focus announces selection only when selected. A focus snapshot can
|
||||||
|
// already contain the cleared state (for example Escape on a Mozilla
|
||||||
|
// attachment), but it has not announced that deselection to the user.
|
||||||
|
if event.Type == atspi.SelectedStateEvent && event.Source.Selected && sameSelectionItem(event.Source, state.current) {
|
||||||
logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role)
|
logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,49 @@ func TestProcessEventsSuppressesQtPopupSelectionRepeats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessEventsPresentsDeselectionAfterRowFocus(t *testing.T) {
|
||||||
|
for _, role := range []string{"list item", "tree item"} {
|
||||||
|
t.Run(role, func(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 }()
|
||||||
|
|
||||||
|
row := atspi.Snapshot{BusName: ":1.7", Path: "/attachments/1", Name: "Attachment",
|
||||||
|
Role: role, Focused: true, Selected: true}
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, row)
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, row)
|
||||||
|
// Mozilla can reassert focus with the new selection state before
|
||||||
|
// emitting selected:false. Focus omits the negative state, so the
|
||||||
|
// subsequent deselection still needs its own announcement.
|
||||||
|
row.Selected = false
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, row)
|
||||||
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, row)
|
||||||
|
focused.Detail1 = 1
|
||||||
|
adapter.events <- focused
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, row)
|
||||||
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent,
|
||||||
|
atspi.Snapshot{BusName: row.BusName, Path: "/done", Name: "Done", Role: "button"})
|
||||||
|
for _, want := range []string{"Attachment, " + role + ", selected", "Attachment, " + role,
|
||||||
|
"not selected", "Done, button"} {
|
||||||
|
select {
|
||||||
|
case got := <-speech.spoken:
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("speech = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)}
|
||||||
|
|||||||
@@ -197,30 +197,47 @@ func TestWorkerUsesNewestFocusAfterConcurrentCapture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkerRecapturesAndRepairsStaleCursor(t *testing.T) {
|
func TestWorkerRecapturesAndRepairsStaleCursor(t *testing.T) {
|
||||||
capturer := &fakeCapturer{calls: make(chan captureCall, 2)}
|
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
|
||||||
worker, cancel := startWorker(t, capturer, time.Second)
|
worker, err := NewWorker(capturer, time.Second)
|
||||||
defer cancel()
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
firstTree := threeNodeTree(t)
|
firstTree := threeNodeTree(t)
|
||||||
worker.SyncFocus(objectID("/first"))
|
cursor, err := NewCursor(firstTree)
|
||||||
if err := worker.Move(NextSibling); err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
firstCall := <-capturer.calls
|
focus := objectID("/first")
|
||||||
firstCall.result <- captureResult{tree: firstTree}
|
worker.SyncFocus(focus)
|
||||||
if result := waitResult(t, worker); result.Err != nil || result.Position.Current.Name != "Second" {
|
if _, err := cursor.SyncFocus(focus); err != nil {
|
||||||
t.Fatalf("initial result = %#v", result)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if position, moved, err := cursor.Move(NextSibling); err != nil || !moved || position.Current.Name != "Second" {
|
||||||
|
t.Fatalf("initial move = %#v, %v, %v", position, moved, err)
|
||||||
|
}
|
||||||
|
// Establish stale state before starting the move. A pending wakeFocus in
|
||||||
|
// a running worker can otherwise repair this cursor during invalidation,
|
||||||
|
// so the queued move legitimately needs no capture and the test hangs.
|
||||||
firstTree.Invalidate(objectID("/second"))
|
firstTree.Invalidate(objectID("/second"))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
if err := worker.Move(NextSibling); err != nil {
|
defer cancel()
|
||||||
t.Fatal(err)
|
results := make(chan moveResult, 1)
|
||||||
|
go func() { results <- worker.move(ctx, cursor, NextSibling) }()
|
||||||
|
select {
|
||||||
|
case call := <-capturer.calls:
|
||||||
|
call.result <- captureResult{tree: threeNodeTree(t)}
|
||||||
|
case result := <-results:
|
||||||
|
t.Fatalf("stale cursor did not recapture: %#v", result)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for stale cursor capture")
|
||||||
}
|
}
|
||||||
secondCall := <-capturer.calls
|
select {
|
||||||
secondCall.result <- captureResult{tree: threeNodeTree(t)}
|
case result := <-results:
|
||||||
result := waitResult(t, worker)
|
if result.Err != nil || !result.Moved || result.Position.Current.Name != "Second" {
|
||||||
if result.Err != nil || !result.Moved || result.Position.Current.Name != "Second" {
|
t.Fatalf("recovered result = %#v, want Second", result)
|
||||||
t.Fatalf("recovered result = %#v, want Second", result)
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for recovered move")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user