Merge Hunter announcement support into testing
This commit is contained in:
commit
27867a48cb
14 files changed
+393
-2
No files matched your search
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits
|
||||
// object:property-change:accessible-name and then object:announcement
|
||||
// (detail1=1, polite) with the same text on a label in the niri application,
|
||||
// about 10ms before the newly focused window's activation events.
|
||||
func announcementEvent(text string, politeness int) atspi.Event {
|
||||
source := atspi.Snapshot{BusName: ":1.20", Path: "/org/a11y/atspi/accessible/2", Role: "label", Name: text, ApplicationName: "niri"}
|
||||
event := atspi.NewEventWithPayload(atspi.AnnouncementEvent, source, text)
|
||||
event.Detail1 = politeness
|
||||
return event
|
||||
}
|
||||
|
||||
func announcementFixture(t *testing.T) (*fakeSpeechQueue, *fakeAdapter, func()) {
|
||||
t.Helper()
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
}()
|
||||
return speech, adapter, func() { cancel(); <-done }
|
||||
}
|
||||
|
||||
func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
|
||||
!request.Append || !request.BypassFocusGate {
|
||||
t.Fatalf("announcement request = %+v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("announcement was not spoken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementAssertiveInterrupts(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Config parse error", 2)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != "Config parse error" || request.Append {
|
||||
t.Fatalf("assertive request = %+v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("assertive announcement was not spoken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
// Niri can append a space to force re-emission of identical text.
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld ", 1)
|
||||
adapter.events <- announcementEvent("Workspace 1 Generic Shit", 1)
|
||||
|
||||
var got []string
|
||||
for range 2 {
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
got = append(got, request.Text)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("announcements spoken = %v", got)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
if got[0] != "Workspace 2 Mistworld" || got[1] != "Workspace 1 Generic Shit" {
|
||||
t.Fatalf("announcements = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent(" ", 1)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
t.Fatalf("empty announcement spoken: %+v", request)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,13 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
"selection_start", event.Source.SelectionStart,
|
||||
"selection_end", event.Source.SelectionEnd)
|
||||
}
|
||||
if event.Type == atspi.AnnouncementEvent {
|
||||
// Announcements are explicit requests to speak from providers that
|
||||
// may never own focus or the foreground window. Present them before
|
||||
// any ownership gating, and never let them move focus state.
|
||||
handler.presentAnnouncement(event)
|
||||
return
|
||||
}
|
||||
syncForeground()
|
||||
if handler.handle_window_switcher(event) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// AtspiLive assertive value carried in an announcement event's Detail1.
|
||||
const announcementAssertive = 2
|
||||
|
||||
// presentAnnouncement speaks an object:announcement payload, mirroring Orca:
|
||||
// the provider's text is presented verbatim, assertive announcements
|
||||
// interrupt, and polite ones append. Announcements commonly come from
|
||||
// providers that never own focus (a compositor announcing a workspace
|
||||
// switch, for example), so no focus or foreground ownership is required and
|
||||
// none is taken. The caller's lock gate has already run; the speech output
|
||||
// gate still rejects the request while the screen is locked.
|
||||
func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event) {
|
||||
text := strings.TrimSpace(event.Payload)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
state := handler.state
|
||||
// Some providers force re-emission of an unchanged message by altering
|
||||
// trailing whitespace. Treat one trimmed repeat from the same provider
|
||||
// inside the live-region dedupe window as the same announcement.
|
||||
if text == state.lastAnnouncement && event.Source.BusName == state.lastAnnouncementBus &&
|
||||
time.Since(state.lastAnnouncementAt) < 300*time.Millisecond {
|
||||
return
|
||||
}
|
||||
state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = text, event.Source.BusName, time.Now()
|
||||
handler.speech.Enqueue(speechRequest{
|
||||
Text: text,
|
||||
Role: "system",
|
||||
Cause: atspi.AnnouncementEvent,
|
||||
Append: event.Detail1 != announcementAssertive,
|
||||
BypassFocusGate: true,
|
||||
})
|
||||
}
|
||||
@@ -97,6 +97,9 @@ type eventPresentationState struct {
|
||||
lastSound string
|
||||
lastLiveText string
|
||||
lastLiveAt time.Time
|
||||
lastAnnouncement string
|
||||
lastAnnouncementBus string
|
||||
lastAnnouncementAt time.Time
|
||||
comboHistory comboPresentation
|
||||
echoPreview echoPreviewPresentation
|
||||
// Retain this identity after the history window closes so delayed GTK
|
||||
@@ -166,6 +169,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
|
||||
state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0
|
||||
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
||||
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
||||
state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = "", "", time.Time{}
|
||||
state.comboHistory = comboPresentation{}
|
||||
state.echoPreview = echoPreviewPresentation{}
|
||||
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
|
||||
|
||||
@@ -67,6 +67,7 @@ const (
|
||||
DocumentLoadCompleteEvent = "document:load-complete"
|
||||
DocumentLoadStoppedEvent = "document:load-stopped"
|
||||
BusyStateEvent = "object:state-changed:busy"
|
||||
AnnouncementEvent = "object:announcement"
|
||||
)
|
||||
|
||||
var eventTypes = []string{
|
||||
@@ -96,6 +97,7 @@ var eventTypes = []string{
|
||||
DocumentLoadCompleteEvent,
|
||||
DocumentLoadStoppedEvent,
|
||||
BusyStateEvent,
|
||||
AnnouncementEvent,
|
||||
"object:state-changed:checked",
|
||||
"object:state-changed:expanded",
|
||||
"object:state-changed:indeterminate",
|
||||
|
||||
@@ -566,7 +566,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
progressUpdate := snapshot.Role == "progress bar" && (eventType == ValueChangedEvent || eventType == AccessibleValueEvent)
|
||||
inactiveProgress := progressUpdate && !adapter.Foreground().Client.Known && C.cthulhu_in_inactive_window(event.source) != 0
|
||||
if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent &&
|
||||
eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && !snapshot.AutocompletePopup &&
|
||||
eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup &&
|
||||
C.cthulhu_in_inactive_window(event.source) != 0 {
|
||||
C.g_free(C.gpointer(payload))
|
||||
if ownedTarget {
|
||||
|
||||
@@ -17,6 +17,11 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
|
||||
switch eventType {
|
||||
case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent:
|
||||
return false
|
||||
case AnnouncementEvent:
|
||||
// An announcement is an explicit request to speak, commonly from a
|
||||
// provider that never owns focus (for example a compositor's own
|
||||
// accessible tree). Foreground ownership must not discard it.
|
||||
return false
|
||||
}
|
||||
foreground := adapter.Foreground()
|
||||
if !foreground.Client.Known || webNotification {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNativeAnnouncementEventDelivered(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_ANNOUNCEMENT") != "1" {
|
||||
t.Skip("requires private Xorg/AT-SPI session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
adapter := New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
|
||||
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_announce.py")
|
||||
command.Env = append(os.Environ(), "GDK_BACKEND=x11")
|
||||
input, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
|
||||
reader := bufio.NewReader(output)
|
||||
line, err := reader.ReadString('\n')
|
||||
if fields := strings.Fields(line); err != nil || len(fields) != 2 || fields[0] != "ready" {
|
||||
t.Fatalf("fixture readiness: %q, %v", line, err)
|
||||
}
|
||||
// Publish the complete AT-SPI cache before announcing.
|
||||
if _, err := adapter.CaptureDesktop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wait := func(text string) Event {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Type == AnnouncementEvent && event.Payload == text {
|
||||
return event
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("announcement %q missing: %v; filtered=%d", text, ctx.Err(), adapter.backgroundEventsFiltered.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(input, "announce polite Workspace 2 Fixture")
|
||||
polite := wait("Workspace 2 Fixture")
|
||||
t.Logf("polite announcement detail1=%d role=%s", polite.Detail1, polite.Source.Role)
|
||||
fmt.Fprintln(input, "announce assertive Fixture error")
|
||||
assertive := wait("Fixture error")
|
||||
t.Logf("assertive announcement detail1=%d role=%s", assertive.Detail1, assertive.Source.Role)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAnnouncementEventIsRegistered(t *testing.T) {
|
||||
if AnnouncementEvent != "object:announcement" {
|
||||
t.Fatalf("AnnouncementEvent = %q", AnnouncementEvent)
|
||||
}
|
||||
for _, eventType := range eventTypes {
|
||||
if eventType == AnnouncementEvent {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("object:announcement is not in the registered event types")
|
||||
}
|
||||
Reference in new issue
Block a user