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") } }) } }