Files
cthulhu/internal/app/steam_activation_test.go

275 lines
9.3 KiB
Go

// 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"
)
func TestWindowTitleChangesSuppressRepeatedNames(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
activation.Source.Name, activation.Source.Role = "Original title", "frame"
activation.WindowTitle = "Original title"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Original title, frame")
changed := activation
changed.Type = atspi.AccessibleNameEvent
for _, title := range []string{"Original title", "Intermediate title", "Updated title", "Updated title", "", "Updated title"} {
changed.Source.Name = title
adapter.events <- changed
}
expectActivationSpeech(t, queue.spoken, "Updated title")
select {
case got := <-queue.spoken:
t.Fatalf("burst queued extra title %q", got)
default:
}
}
func TestWindowActivationEstablishesFocusedObjectOwnership(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
focused := atspi.Snapshot{
BusName: ":1.70",
Path: "/focused",
ApplicationName: "steamwebhelper",
Name: "Library",
Role: "check box",
Focused: true,
}
activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused)
activation.OriginPath = "/window"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Library, check box, not checked")
duplicate := activation
duplicate.Source.Name = "Duplicate activation"
adapter.events <- duplicate
select {
case got := <-queue.spoken:
t.Fatalf("duplicate activation spoke %q", got)
case <-time.After(30 * time.Millisecond):
}
// Accessible paths are process-local. A background process using the same
// path must not be mistaken for the active Steam object.
background := focused
background.BusName = ":1.71"
background.ApplicationName = "Background Application"
background.Name = "Background"
background.Checked = true
changed := atspi.NewEvent("object:state-changed:checked", background)
changed.Detail1 = 1
adapter.events <- changed
select {
case got := <-queue.spoken:
t.Fatalf("background path collision spoke %q", got)
case <-time.After(30 * time.Millisecond):
}
focused.Checked = true
changed = atspi.NewEvent("object:state-changed:checked", focused)
changed.Detail1 = 1
adapter.events <- changed
expectActivationSpeech(t, queue.spoken, "checked")
}
func TestFocusBeforeActivationAnnouncesWindowTitleOnce(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusedStateEvent, ":1.70", "/window", "/entry")
focus.Source.Name = "Search"
focus.Source.Role = "entry"
focus.Source.Focused = true
focus.Detail1 = 1
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Search, entry")
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
activation.Source.Name = "Example Window"
activation.Source.Role = "frame"
activation.WindowTitle = "Example Window"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Example Window")
adapter.events <- activation
select {
case got := <-queue.spoken:
t.Fatalf("repeated activation spoke %q", got)
case <-time.After(30 * time.Millisecond):
}
}
func TestActiveWindowNameChangeAnnouncesOnlyCurrentTitle(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
activation.Source.Name = "Original title"
activation.Source.Role = "frame"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Original title, frame")
background := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/other", "/other")
background.Source.Name = "Background title"
background.Source.Role = "frame"
adapter.events <- background
changed := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/window", "/window")
changed.Source.Name = "Updated title"
changed.Source.Role = "frame"
adapter.events <- changed
expectActivationSpeech(t, queue.spoken, "Updated title")
select {
case got := <-queue.spoken:
t.Fatalf("background title spoke %q", got)
case <-time.After(30 * time.Millisecond):
}
}
func TestRepeatedWindowActivationPreservesFocusedDescendant(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
window := atspi.Snapshot{
BusName: ":1.70",
Path: "/window",
ApplicationName: "Example Application",
Name: "Example Window",
Role: "frame",
}
activation := atspi.NewEvent(atspi.WindowActivatedEvent, window)
activation.OriginPath = window.Path
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Example Window, frame")
focused := atspi.Snapshot{
BusName: window.BusName,
Path: "/focused",
ApplicationName: window.ApplicationName,
Name: "Review target",
Role: "check box",
Focused: true,
}
focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, focused)
focusEvent.Detail1 = 1
adapter.events <- focusEvent
expectActivationSpeech(t, queue.spoken, "Review target, check box, not checked")
// X11 grabs can cause the same top-level activation to be emitted again
// with the frame as its source. It must neither speak nor replace the
// focused descendant used by review and state-change filtering.
repeated := activation
repeated.Source.Name = "Repeated window activation"
adapter.events <- repeated
focused.Checked = true
changed := atspi.NewEvent("object:state-changed:checked", focused)
changed.Detail1 = 1
adapter.events <- changed
expectActivationSpeech(t, queue.spoken, "checked")
}
func TestWindowActivationIsFocusGain(t *testing.T) {
event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{})
if !isFocusEvent(event.Type) || !isFocusGainEvent(event) {
t.Fatal("window activation was not treated as a focus gain")
}
}
func TestWindowLifecycleClearsFocusedObjectOwnership(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
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, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
focused := atspi.Snapshot{
BusName: ":1.70",
Path: "/focused",
ApplicationName: "Example Application",
Name: "Active control",
Role: "check box",
Focused: true,
}
activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused)
activation.Window = atspi.NewObjectID(focused.BusName, "/window")
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "Active control, check box, not checked")
deactivation := atspi.NewEvent(atspi.WindowDeactivatedEvent, atspi.Snapshot{
BusName: focused.BusName,
Path: "/window",
Role: "frame",
})
deactivation.Window = activation.Window
adapter.events <- deactivation
changed := atspi.NewEvent("object:state-changed:checked", focused)
changed.Detail1 = 1
adapter.events <- changed
select {
case got := <-queue.spoken:
t.Fatalf("event from deactivated window spoke %q", got)
case <-time.After(30 * time.Millisecond):
}
}
func expectActivationSpeech(t *testing.T, spoken <-chan string, want string) {
t.Helper()
select {
case got := <-spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing speech %q", want)
}
}