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