415 lines
16 KiB
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):
|
|
}
|
|
})
|
|
}
|
|
}
|