204 lines
7.8 KiB
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")
|
|
}
|
|
})
|
|
}
|
|
}
|