263 lines
7.5 KiB
Go
263 lines
7.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/notification"
|
|
)
|
|
|
|
type commandGrabRecorder struct {
|
|
commands chan input.CommandEvent
|
|
failures chan error
|
|
mutex sync.Mutex
|
|
values []bool
|
|
}
|
|
|
|
func (recorder *commandGrabRecorder) Commands() <-chan input.CommandEvent {
|
|
return recorder.commands
|
|
}
|
|
|
|
func (recorder *commandGrabRecorder) LockStates() <-chan input.LockState { return nil }
|
|
func (recorder *commandGrabRecorder) Failures() <-chan error { return recorder.failures }
|
|
func (recorder *commandGrabRecorder) Start(context.Context) error { return nil }
|
|
func (recorder *commandGrabRecorder) Stop(context.Context) error { return nil }
|
|
|
|
func (recorder *commandGrabRecorder) SetCommandGrabs(enabled bool) {
|
|
recorder.mutex.Lock()
|
|
defer recorder.mutex.Unlock()
|
|
recorder.values = append(recorder.values, enabled)
|
|
}
|
|
|
|
func (recorder *commandGrabRecorder) grabs() []bool {
|
|
recorder.mutex.Lock()
|
|
defer recorder.mutex.Unlock()
|
|
return append([]bool(nil), recorder.values...)
|
|
}
|
|
|
|
func TestXTermFocusSuspendsAndResumesPresentationAndCommandGrabs(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
commands := &commandGrabRecorder{
|
|
commands: make(chan input.CommandEvent, 1),
|
|
failures: make(chan error, 1),
|
|
}
|
|
gate := newFocusGate(commands)
|
|
gate.speech = speech
|
|
history := notification.NewPresenter()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
defer close(completed)
|
|
processEventsWithInput(
|
|
ctx,
|
|
slog.New(slog.DiscardHandler),
|
|
outputQueue{speech: gatedSpeech{speech: speech, gate: gate}},
|
|
adapter,
|
|
nil,
|
|
nil,
|
|
0,
|
|
nil,
|
|
nil,
|
|
gate,
|
|
history,
|
|
)
|
|
}()
|
|
|
|
home := atspi.NewSnapshot("/home", "Home", "push button", "", "")
|
|
home.ApplicationName = "Browser"
|
|
terminal := atspi.NewSnapshot("/terminal", "XTerm", "terminal", "", "Prompt")
|
|
terminal.ApplicationName = "xterm"
|
|
notification := atspi.NewSnapshot("/notification", "", "frame", "", "")
|
|
notification.ApplicationName = "xfce4-notifyd"
|
|
notification.Text = "Notification ready"
|
|
browser := atspi.NewSnapshot("/browser", "Browser", "push button", "", "")
|
|
browser.ApplicationName = "Browser"
|
|
|
|
xt := atspi.NewEvent(atspi.FocusedStateEvent, terminal)
|
|
xt.Detail1 = 1
|
|
expectSpeech := func(want string) {
|
|
t.Helper()
|
|
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 speech %q", want)
|
|
}
|
|
}
|
|
expectInterrupt := func() {
|
|
t.Helper()
|
|
select {
|
|
case <-speech.interrupt:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("XTerm focus did not interrupt Cthulhu speech")
|
|
}
|
|
}
|
|
waitForGrabs := func(want []bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
if got := commands.grabs(); equalBools(got, want) {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("command grabs = %v, want %v", commands.grabs(), want)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, home)
|
|
expectSpeech("Home, push button")
|
|
adapter.events <- xt
|
|
expectInterrupt()
|
|
waitForGrabs([]bool{false})
|
|
|
|
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "s")
|
|
time.Sleep(20 * time.Millisecond)
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("XTerm terminal event was presented as %q", got)
|
|
default:
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent("window:create", notification)
|
|
expectSpeech("Notification Notification ready")
|
|
if !gate.Suspended() || !equalBools(commands.grabs(), []bool{false}) {
|
|
t.Fatal("notification restored XTerm keyboard handling")
|
|
}
|
|
if entries := history.Entries(); len(entries) != 1 || entries[0].Message != "Notification Notification ready" {
|
|
t.Fatalf("notification history = %+v", entries)
|
|
}
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, browser)
|
|
expectSpeech("Browser, push button")
|
|
waitForGrabs([]bool{false, true})
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func TestXTermFocusHonorsKnownApplication(t *testing.T) {
|
|
other := atspi.NewEvent(atspi.FocusedStateEvent, atspi.Snapshot{Role: "terminal", ApplicationName: "TestTerminal"})
|
|
other.Detail1 = 1
|
|
if isXTermFocus(other) {
|
|
t.Fatal("known non-XTerm terminal was classified as XTerm")
|
|
}
|
|
xterm := other
|
|
xterm.Source.ApplicationName = "xterm"
|
|
if !isXTermFocus(xterm) {
|
|
t.Fatal("XTerm application focus was not classified as XTerm")
|
|
}
|
|
}
|
|
|
|
func TestXTermFocusDiscardsPendingSpeech(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
|
|
gate := newFocusGate(nil)
|
|
gate.speech = speech
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
defer close(completed)
|
|
processEventsWithInput(
|
|
ctx,
|
|
slog.New(slog.DiscardHandler),
|
|
speech,
|
|
adapter,
|
|
nil,
|
|
nil,
|
|
time.Hour,
|
|
nil,
|
|
nil,
|
|
gate,
|
|
nil,
|
|
)
|
|
}()
|
|
|
|
entry := atspi.NewSnapshot("/entry", "Entry", "text", "", "Pending")
|
|
entry.Editable = true
|
|
terminal := atspi.NewSnapshot("/terminal", "XTerm", "terminal", "", "Prompt")
|
|
terminal.ApplicationName = "xterm"
|
|
|
|
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
|
|
xt := atspi.NewEvent(atspi.FocusedStateEvent, terminal)
|
|
xt.Detail1 = 1
|
|
adapter.events <- xt
|
|
|
|
select {
|
|
case <-speech.interrupt:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("XTerm focus did not interrupt pending speech")
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("pending event was presented as %q", got)
|
|
default:
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-completed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("event loop did not stop")
|
|
}
|
|
}
|
|
|
|
func equalBools(left, right []bool) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index, value := range left {
|
|
if value != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Exercise the actual XRecord consumer, not only direct gate calls. Closing the
|
|
// input stream provides a deterministic barrier for all queued modifier events.
|
|
func TestDelegatedInputSuppressesInterruptAndEcho(t *testing.T) {
|
|
for _, suspended := range []bool{true, false} {
|
|
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 16), interrupt: make(chan struct{}, 16)}
|
|
gate := newFocusGate(nil)
|
|
gate.SetSuspended(true)
|
|
if !suspended {
|
|
gate.SetSuspended(false)
|
|
}
|
|
listener := &fakeInputListener{events: make(chan input.Event, 4), modifierKeycodes: input.NewModifierKeycodes(37, 50)}
|
|
for _, code := range []uint8{37, 50} {
|
|
listener.events <- input.Event{Keycode: code, Pressed: true}
|
|
listener.events <- input.Event{Keycode: code, Pressed: false}
|
|
}
|
|
close(listener.events)
|
|
processInputEvents(context.Background(), slog.New(slog.DiscardHandler), gatedSpeech{speech, gate}, listener, input.EchoSettings{}, nil, nil)
|
|
want := 2
|
|
if suspended {
|
|
want = 0
|
|
}
|
|
if len(speech.interrupt) != want {
|
|
t.Fatalf("interrupts = %d, want %d", len(speech.interrupt), want)
|
|
}
|
|
gatedSpeech{speech, gate}.Enqueue(speechRequest{Text: "key echo"})
|
|
if suspended && len(speech.spoken) != 0 {
|
|
t.Fatal("delegated echo reached speech")
|
|
}
|
|
})
|
|
}
|
|
}
|