Files
cthulhu/internal/app/xterm_handoff_test.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")
}
})
}
}