144 lines
4.6 KiB
Go
144 lines
4.6 KiB
Go
// 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"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
)
|
|
|
|
// 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 Example", 1)
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != "Workspace 2 Example" || 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 TestAnnouncementAssertiveQueues(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 Example", 1)
|
|
// Niri can append a space to force re-emission of identical text.
|
|
adapter.events <- announcementEvent("Workspace 2 Example ", 1)
|
|
adapter.events <- announcementEvent("Workspace 1 Example", 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 Example" || got[1] != "Workspace 1 Example" {
|
|
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):
|
|
}
|
|
}
|
|
|
|
func TestAnnouncementBrailleAndLockGate(t *testing.T) {
|
|
live := &liveSettings{}
|
|
live.Store(config.DefaultSettings())
|
|
speech := &remoteSpeechQueue{}
|
|
display := &flashRecorder{}
|
|
gate := newFocusGate(nil)
|
|
handler := &accessibilityEventHandler{
|
|
state: &eventLoopState{}, focusGate: gate,
|
|
speech: gatedSpeech{gate: gate, speech: outputQueue{speech: speech, braille: display, live: live}},
|
|
}
|
|
event := announcementEvent("Workspace 2 Example", 1)
|
|
handler.presentAnnouncement(event)
|
|
if len(display.messages) != 1 || display.messages[0] != event.Payload {
|
|
t.Fatalf("Braille flashes = %v", display.messages)
|
|
}
|
|
gate.secure.Store(true)
|
|
handler.handle(announcementEvent("Locked workspace", 1))
|
|
if len(display.messages) != 1 {
|
|
t.Fatal("locked announcement flashed")
|
|
}
|
|
}
|
|
|
|
func TestIdenticalAnnouncementFromAnotherProviderIsPreserved(t *testing.T) {
|
|
speech := &remoteSpeechQueue{}
|
|
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
|
|
event := announcementEvent("Example message", 1)
|
|
handler.presentAnnouncement(event)
|
|
event.Source.BusName = ":1.21"
|
|
handler.presentAnnouncement(event)
|
|
if len(speech.requests) != 2 {
|
|
t.Fatalf("requests = %d, want 2", len(speech.requests))
|
|
}
|
|
}
|