275 lines
9.3 KiB
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)
|
|
}
|
|
}
|