Files
cthulhu/internal/app/event_focus_test.go

415 lines
16 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
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/input"
)
func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(completed)
}()
adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", ""))
saveFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", ""))
saveFocused.Detail1 = 1
adapter.events <- saveFocused
cancelFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/cancel", "Cancel", "push button", "", ""))
cancelFocused.Detail1 = 1
adapter.events <- cancelFocused
for _, expected := range []string{"Save, push button", "Cancel, push button"} {
select {
case text := <-speech.spoken:
if text != expected {
t.Fatalf("spoken text = %q, want %q", text, expected)
}
case <-time.After(time.Second):
t.Fatalf("timed out waiting for speech %q", expected)
}
}
cancel()
select {
case <-completed:
case <-time.After(time.Second):
t.Fatal("timed out waiting for event loop to stop")
}
}
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
completed := make(chan struct{})
go func() {
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil,
fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil)
close(completed)
}()
defer func() { cancel(); <-completed }()
frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "")
frame.BusName, frame.ApplicationName = ":1.45", "thunar"
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
activated.Window = frame.ID()
adapter.events <- activated
<-speech.spoken
file := atspi.NewSnapshot("/thunar/file", "Audio", "table cell", "", "")
file.BusName, file.ApplicationName = frame.BusName, "thunar"
focused := atspi.NewEvent(atspi.FocusedStateEvent, file)
focused.Detail1, focused.Window = 1, frame.ID()
adapter.events <- focused
if got := <-speech.spoken; got != "Audio" {
t.Fatalf("file speech = %q", got)
}
for _, role := range []string{"directory pane", "status bar"} {
changed := atspi.NewSnapshot("/thunar/"+role, "Updated "+role, role, "", "")
changed.BusName, changed.ApplicationName = frame.BusName, "thunar"
event := atspi.NewEvent(atspi.AccessibleNameEvent, changed)
event.Window = frame.ID()
adapter.events <- event
}
select {
case got := <-speech.spoken:
t.Fatalf("unfocused name change interrupted file: %q", got)
case <-time.After(30 * time.Millisecond):
}
status := atspi.NewSnapshot("/thunar/status bar", "Selected file", "status bar", "", "")
status.BusName, status.ApplicationName = frame.BusName, "thunar"
statusFocus := atspi.NewEvent(atspi.FocusedStateEvent, status)
statusFocus.Detail1, statusFocus.Window = 1, frame.ID()
adapter.events <- statusFocus
if got := <-speech.spoken; got != "Selected file, status bar" {
t.Fatalf("focused status bar speech = %q", got)
}
status.Name = "Updated status"
statusChanged := atspi.NewEvent(atspi.AccessibleNameEvent, status)
statusChanged.Window = frame.ID()
adapter.events <- statusChanged
if got := <-speech.spoken; got != "Updated status, status bar" {
t.Fatalf("focused status bar update = %q", got)
}
}
func TestProcessEventsFiltersUnfocusedStateAndValueChanges(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(completed)
}()
focused := atspi.Snapshot{Path: "/volume", Name: "Volume", Role: "slider", ValueKnown: true,
CurrentValue: 20, MinimumValue: 0, MaximumValue: 100}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, focused)
if got := <-speech.spoken; got != "Volume, slider, 20" {
t.Fatalf("focus speech = %q", got)
}
unfocused := atspi.Snapshot{Path: "/other", Name: "Other", Role: "slider", ValueKnown: true,
CurrentValue: 90, MinimumValue: 0, MaximumValue: 100}
adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, unfocused)
adapter.events <- atspi.NewEvent("object:state-changed:sensitive", atspi.Snapshot{
Path: "/other", Name: "Other", Role: "push button", SensitiveKnown: true,
})
focused.CurrentValue = 30
adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, focused)
select {
case got := <-speech.spoken:
if got != "30" {
t.Fatalf("value-change speech = %q", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for focused value change")
}
select {
case got := <-speech.spoken:
t.Fatalf("unexpected unfocused event speech: %q", got)
default:
}
cancel()
<-completed
}
func TestProcessEventsPresentsFocusedManagedDescendant(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(completed)
}()
list := atspi.NewSnapshot("/run-dialog/results", "Run dialog suggestions", "list", "", "")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, list)
if got := <-speech.spoken; got != "Run dialog suggestions, list" {
t.Fatalf("list focus speech = %q", got)
}
result := atspi.NewSnapshot("/run-dialog/results/1", "LibreOffice Writer", "table cell", "", "")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result)
if got := <-speech.spoken; got != "LibreOffice Writer" {
t.Fatalf("selected result speech = %q", got)
}
// GTK can emit both selection and active-descendant events for the same row.
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, result)
select {
case got := <-speech.spoken:
t.Fatalf("duplicate managed-descendant speech = %q", got)
case <-time.After(30 * time.Millisecond):
}
// A feed refresh can briefly expose defunct descendants and repeatedly
// select the same row while updating its relative timestamp. None of
// these events represents navigation or a new post.
invalid := atspi.NewSnapshot("/run-dialog/results/defunct", "", "invalid", "", "")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, invalid)
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result)
updated := result
updated.Name = "LibreOffice Writer, 2 minutes ago"
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, updated)
// GTK can move explicit focus to the current child before reporting a
// sibling as the container's new selected descendant. The focused child
// remains owned by the same managed container, so the sibling change is
// still relevant.
focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, result)
focusEvent.Detail1 = 1
adapter.events <- focusEvent
sibling := atspi.NewSnapshot("/run-dialog/results/2", "Calculator", "table cell", "", "")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, sibling)
if got := <-speech.spoken; got != "Calculator" {
t.Fatalf("sibling result speech = %q", got)
}
other := atspi.NewSnapshot("/other/results/1", "Unrelated", "table cell", "", "")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/other/results", other)
select {
case got := <-speech.spoken:
t.Fatalf("unfocused selection speech = %q", got)
case <-time.After(30 * time.Millisecond):
}
cancel()
<-completed
}
func TestGeckoCurrentButtonNameArrivesAfterFocus(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
frames := make(chan braille.Frame, 3)
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: speech, braille: frameRecorder{frames}}, adapter, 0)
close(done)
}()
defer func() { cancel(); <-done }()
button := atspi.NewSnapshot("/mail/toolbar/button", "", "button", "", "")
button.ToolkitName = "Gecko"
adapter.events <- atspi.NewEvent(atspi.FocusEvent, button)
if got := <-speech.spoken; got != "button" {
t.Fatalf("initial unnamed button = %q", got)
}
<-frames
button.Name = "Open calendar"
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, button)
select {
case got := <-speech.spoken:
if got != "Open calendar, button" {
t.Fatalf("updated button name = %q", got)
}
case <-time.After(time.Second):
t.Fatal("updated Gecko button name was not spoken")
}
select {
case frame := <-frames:
if frame.Text != "Open calendar button" {
t.Fatalf("updated button braille = %q", frame.Text)
}
case <-time.After(time.Second):
t.Fatal("updated Gecko button name did not refresh braille")
}
}
func TestReusedTableRowSelectionReadsChangedMessage(t *testing.T) {
speech := &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() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(done)
}()
defer func() { cancel(); <-done }()
row := atspi.NewSnapshot("/mail/list/row", "First message", "table row", "", "")
row.ToolkitName = "Gecko"
adapter.events <- atspi.NewEvent(atspi.FocusEvent, row)
if got := <-speech.spoken; got != "First message, table row" {
t.Fatalf("first message = %q", got)
}
selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", row)
adapter.events <- selected
replacement := row
replacement.Name = "Next message"
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", replacement)
select {
case got := <-speech.spoken:
if got != "Next message, table row" {
t.Fatalf("replacement message = %q", got)
}
case <-time.After(time.Second):
t.Fatal("replacement table row was not spoken")
}
third := replacement
third.Name = "Third message"
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, third)
select {
case got := <-speech.spoken:
if got != "Third message, table row" {
t.Fatalf("renamed message = %q", got)
}
case <-time.After(time.Second):
t.Fatal("renamed table row was not spoken")
}
}
func TestFileManagerStatusFallbackRequiresOwnedUnnamedNavigationFocus(t *testing.T) {
window := atspi.NewObjectID(":1.45", "/thunar/window")
owner := windowOwner{applicationBus: window.BusName, window: window}
status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "")
status.BusName = window.BusName
status.ApplicationName = "thunar"
event := atspi.NewEvent(atspi.AccessibleNameEvent, status)
event.Window = window
panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "")
if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) {
t.Fatal("selected-file status was lost while focus remained on the file-view panel")
}
event.Source.ApplicationName = "pcmanfm"
if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) {
t.Fatal("PCManFM icon selection status was lost while focus remained on its panel")
}
event.Source.ApplicationName = "thunar"
for _, test := range []struct {
name string
modify func(*atspi.Event, *atspi.Snapshot, *windowOwner)
intent input.Intent
}{
{"no navigation key", nil, input.NoIntent},
{"named file focus", func(_ *atspi.Event, focus *atspi.Snapshot, _ *windowOwner) {
focus.Role, focus.Name = "table cell", "Beta"
}, input.LineIntent},
{"other window", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) {
event.Window = atspi.NewObjectID(":1.45", "/other/window")
}, input.LineIntent},
{"other application", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.ApplicationName = "editor" }, input.LineIntent},
{"empty status", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.Name = "" }, input.LineIntent},
} {
t.Run(test.name, func(t *testing.T) {
candidate, focus, active := event, panel, owner
if test.modify != nil {
test.modify(&candidate, &focus, &active)
}
if shouldPresentFileManagerStatus(candidate, focus, active, test.intent) {
t.Fatal("unrelated status change took over file navigation")
}
})
}
}
func TestProcessEventsSpeaksFileManagerSelectionStatusAfterPanelFocus(t *testing.T) {
for _, applicationName := range []string{"thunar", "pcmanfm"} {
t.Run(applicationName, func(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
completed := make(chan struct{})
go func() {
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil,
fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil)
close(completed)
}()
defer func() { cancel(); <-completed }()
frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "")
frame.BusName, frame.ApplicationName = ":1.45", applicationName
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
activated.Window = frame.ID()
adapter.events <- activated
if got := <-speech.spoken; got != "Thunar, frame" {
t.Fatalf("window speech = %q", got)
}
panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "")
panel.BusName, panel.ApplicationName = frame.BusName, applicationName
focused := atspi.NewEvent(atspi.FocusedStateEvent, panel)
focused.Detail1, focused.Window = 1, frame.ID()
adapter.events <- focused
if got := <-speech.spoken; got != "panel" {
t.Fatalf("panel speech = %q", got)
}
status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "")
status.BusName, status.ApplicationName = frame.BusName, applicationName
changed := atspi.NewEvent(atspi.AccessibleNameEvent, status)
changed.Window = frame.ID()
adapter.events <- changed
if got := <-speech.spoken; got != status.Name {
t.Fatalf("selected file speech = %q, want %q", got, status.Name)
}
adapter.events <- changed
select {
case got := <-speech.spoken:
t.Fatalf("duplicate status speech = %q", got)
case <-time.After(30 * time.Millisecond):
}
})
}
}