Merge Hunter announcement support into testing

This commit is contained in:
Storm Dragon committed 2026-10-31 15:48:42 -04:00
commit 27867a48cb
14 files changed
+393 -2

No files matched your search

+108
View File
@@ -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):
}
}
+7
View File
@@ -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
+46
View File
@@ -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,
})
}
+4
View File
@@ -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 = "", "", ""
+2
View File
@@ -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)
}
+18
View File
@@ -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")
}