Files
cthulhu/internal/app/foreground_test.go

204 lines
7.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"log/slog"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
type foregroundAdapter struct {
fakeAdapter
state atomic.Pointer[atspi.ForegroundState]
changes chan struct{}
}
func TestForegroundAutocompletePopupRequiresOwnedEntry(t *testing.T) {
a := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)}
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
a.state.Store(&state)
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
keys := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, a, nil, nil, 0, nil, keys, nil, nil)
}()
defer func() { cancel(); <-done }()
entry := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/browser", "/address")
entry.Source.Role = "entry"
entry.Source.Autocomplete = true
entry.Source.Editable = true
entry.Source.Focused = true
entry.Detail1 = 1
entry.Foreground = state
a.events <- entry
expectActivationSpeech(t, queue.spoken, "autocomplete")
keys.Observe(input.NewKeyEvent(0, true, "Down"))
popup := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/popup", "/item")
popup.Source.Role = "list item"
popup.Source.Name = "Suggestion"
popup.Source.Selected = true
popup.Source.AutocompletePopup = true
popup.Source.Focused = true
popup.Detail1 = 1
popup.PopupFor = entry.Source.ID()
popup.Foreground = state
popup.ForegroundMismatch = true
a.events <- popup
expectActivationSpeech(t, queue.spoken, "Suggestion, list item, selected")
// A different relation target or bus cannot borrow the browser's window.
for _, bad := range []atspi.Event{
func() atspi.Event { e := popup; e.PopupFor = atspi.NewObjectID(":1.40", "/other-entry"); return e }(),
func() atspi.Event { e := popup; e.Source.BusName = ":1.41"; return e }(),
} {
bad.Source.Name = "Background suggestion"
a.events <- bad
}
// The popup must not replace the owned address entry or prevent typing.
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "z")
insertion.Window = entry.Window
insertion.Foreground = state
keys.Observe(input.NewKeyEvent(0, true, "z"))
a.events <- insertion
expectActivationSpeech(t, queue.spoken, "z")
other := ownerEvent(atspi.FocusedStateEvent, ":1.41", "/other-window", "/button")
other.Source.Role = "push button"
other.Source.Name = "Other window"
other.Source.Focused = true
other.Detail1 = 1
other.Foreground = state
a.events <- other
expectActivationSpeech(t, queue.spoken, "Other window, push button")
popup.Source.Name = "Inactive suggestion"
a.events <- popup
other.Source.Name = "Still foreground"
a.events <- other
expectActivationSpeech(t, queue.spoken, "Still foreground, push button")
}
func TestAutocompletePopupNameChangesDoNotCue(t *testing.T) {
adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)}
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
adapter.state.Store(&state)
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)}
keys := &input.IntentTracker{}
options := presentation.DefaultOptions()
options.EnableSound = true
options.PlaySoundForRole = true
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, keys, nil, nil, options)
}()
defer func() { cancel(); <-done }()
entry := ownerEvent(atspi.FocusedStateEvent, ":1.40", "/browser", "/address")
entry.Source.Role, entry.Source.Autocomplete, entry.Source.Editable, entry.Source.Focused = "entry", true, true, true
entry.Detail1, entry.Foreground = 1, state
adapter.events <- entry
if got := <-queue.requests; got.Text != "autocomplete" {
t.Fatalf("focus request = %#v", got)
}
keys.Observe(input.NewKeyEvent(0, true, "g"))
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "g")
insertion.Window, insertion.Foreground = entry.Window, state
adapter.events <- insertion
if got := <-queue.requests; got.Text != "g" || len(got.Sound) != 0 {
t.Fatalf("typing request = %#v", got)
}
popup := ownerEvent(atspi.AccessibleNameEvent, ":1.40", "/popup", "/item")
popup.Source.Role, popup.Source.AutocompletePopup = "list item", true
popup.PopupFor, popup.Foreground, popup.ForegroundMismatch = entry.Source.ID(), state, true
for _, name := range []string{"First suggestion", "Second suggestion", "Third suggestion"} {
popup.Source.Name = name
adapter.events <- popup
}
select {
case got := <-queue.requests:
t.Fatalf("popup name update produced output: %#v", got)
case <-time.After(300 * time.Millisecond):
}
keys.Observe(input.NewKeyEvent(0, true, "Down"))
selected := popup
selected.Type = atspi.FocusedStateEvent
selected.Detail1 = 1
selected.Source.Selected = true
adapter.events <- selected
select {
case got := <-queue.requests:
if got.Text != "Third suggestion, list item, selected" {
t.Fatalf("selected popup item = %#v", got)
}
case <-time.After(time.Second):
t.Fatal("missing selected popup item")
}
}
func (a *foregroundAdapter) Foreground() atspi.ForegroundState { return *a.state.Load() }
func (a *foregroundAdapter) ForegroundChanges() <-chan struct{} { return a.changes }
func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
for _, window := range []uint64{0, 202} {
t.Run(map[uint64]string{0: "empty workspace", 202: "other window"}[window], func(t *testing.T) {
a := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 16)}, changes: make(chan struct{}, 1)}
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
a.state.Store(&state)
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
where := make(chan input.Command)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, a, where, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
event := ownerEvent(atspi.WindowActivatedEvent, ":1.5", "/window", "/button")
event.Source.Name = "Current"
event.Source.Role = "push button"
event.Foreground = state
a.events <- event
expectActivationSpeech(t, queue.spoken, "Current, push button")
next := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: window}, Epoch: 2}
a.state.Store(&next)
// No wakeup is required: event/result consumers must check the latest
// atomic epoch even if the channel notification has not been selected.
event.Source.Name = "Stale"
a.events <- event
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Could not find current location.")
event.Foreground = next
event.ForegroundMismatch = true
a.events <- event
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Could not find current location.")
if window != 0 {
event.ForegroundMismatch = false
event.Source.Name = "New"
a.events <- event
expectActivationSpeech(t, queue.spoken, "New, push button")
deactivated := event
deactivated.Type = atspi.WindowDeactivatedEvent
a.events <- deactivated
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New, push button")
}
})
}
}