// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) type switcherBrailleRecorder struct{ flashes chan string } func (*switcherBrailleRecorder) EnqueueFrame(braille.Frame) {} func (display *switcherBrailleRecorder) Flash(text string, _ config.BrailleOptions) bool { display.flashes <- text return true } func mate_switcher_event(eventType, title string, foreground atspi.ForegroundState) atspi.Event { event := ownerEvent(eventType, ":1.43", "/switcher", "/switcher/status") event.Source.ApplicationName, event.Source.ApplicationProcess = "marco", "marco" event.Source.Role, event.Source.Name = "status bar", title event.Source.StatesKnown, event.Source.Visible, event.Source.Showing = true, true, true event.Foreground, event.ForegroundMismatch = foreground, true return event } func TestMateWindowSwitcherOutsideForeground(t *testing.T) { adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 32)}, changes: make(chan struct{}, 1)} foreground := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} adapter.state.Store(&foreground) queue := &fakeSpeechQueue{spoken: make(chan string, 32), requests: make(chan speechRequest, 32)} live := &liveSettings{} live.Store(config.DefaultSettings()) display := &switcherBrailleRecorder{flashes: make(chan string, 16)} output := outputQueue{speech: queue, braille: display, live: live} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) _ = processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, live) }() defer func() { cancel(); <-done }() focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/editor", "/editor/button") focus.Source.Role, focus.Source.Name, focus.Source.Focused = "push button", "Save", true focus.Detail1, focus.Foreground = 1, foreground adapter.events <- focus expectActivationSpeech(t, queue.spoken, "Save, push button") <-queue.requests // Replay the log's non-focused status insertion, then repeated forward // and reverse selection updates without an X11 foreground change. for _, title := range []string{"Editor", "Terminal", "Editor"} { adapter.events <- mate_switcher_event(atspi.TextInsertedEvent, title, foreground) expectActivationSpeech(t, queue.spoken, title) request := <-queue.requests if request.Cause != "window-switcher" || request.Role != "system" || !request.BypassFocusGate { t.Fatalf("switcher request = %+v", request) } select { case got := <-display.flashes: if got != title { t.Fatalf("Braille title = %q; want %q", got, title) } case <-time.After(time.Second): t.Fatal("missing switcher Braille message") } } showing := mate_switcher_event(atspi.ShowingStateEvent, "Browser", foreground) showing.Detail1 = 1 adapter.events <- showing expectActivationSpeech(t, queue.spoken, "Browser") <-queue.requests adapter.events <- mate_switcher_event(atspi.SelectionChangedEvent, "Terminal", foreground) expectActivationSpeech(t, queue.spoken, "Terminal") <-queue.requests // Duplicate name signals, hidden/destroyed switchers, stale epochs and // another application's status bar cannot borrow the exception. for _, change := range []func(*atspi.Event){ func(e *atspi.Event) { e.Type = atspi.AccessibleNameEvent }, func(e *atspi.Event) { e.Type, e.Detail1 = atspi.ShowingStateEvent, 0 }, func(e *atspi.Event) { e.Source.Role = "label" }, func(e *atspi.Event) { e.Source.StatesKnown = false }, func(e *atspi.Event) { e.Source.Visible = false }, func(e *atspi.Event) { e.Source.Showing = false }, func(e *atspi.Event) { e.Source.Defunct = true }, func(e *atspi.Event) { e.Foreground.Epoch-- }, func(e *atspi.Event) { e.Source.ApplicationName, e.Source.ApplicationProcess = "editor", "editor" }, func(e *atspi.Event) { e.Source.ApplicationProcess = "python3" }, func(e *atspi.Event) { e.Window.BusName = ":1.44" }, func(e *atspi.Event) { e.Source.Name = " " }, } { event := mate_switcher_event(atspi.TextInsertedEvent, "Unwanted output", foreground) change(&event) adapter.events <- event } // A name change on the original control proves that switcher output did // not replace application ownership or the locus of focus. FIFO provides // a barrier for every rejected event above. focus.Type, focus.Source.Name = atspi.AccessibleNameEvent, "Still focused" adapter.events <- focus expectActivationSpeech(t, queue.spoken, "Still focused, push button") } func TestMateWindowSwitcherTerminalHandoffAndLock(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} gate := &focusGate{} gate.suspended.Store(true) handler := accessibilityEventHandler{focusGate: gate, speech: gatedSpeech{speech: queue, gate: gate}} event := mate_switcher_event(atspi.TextInsertedEvent, "Editor", atspi.ForegroundState{}) if !handler.handle_window_switcher(event) { t.Fatal("switcher selection was not handled") } expectActivationSpeech(t, queue.spoken, "Editor") // The top-level accessibility handler must reject the event before the // special path, even though terminal-handoff output is allowed above. gate.secure.Store(true) handler.handle(event) if len(queue.spoken) != 0 { t.Fatal("switcher selection spoke during screen lock") } }