421 lines
16 KiB
Go
421 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"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
)
|
|
|
|
func TestNativeGUIListTableSpeech(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_GUI_LIST_TABLE") != "1" {
|
|
t.Skip("requires isolated GTK table with list role")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer stopCancel()
|
|
if err := adapter.Stop(stopCtx); err != nil {
|
|
t.Errorf("stop adapter: %v", err)
|
|
}
|
|
}()
|
|
tree, err := adapter.CaptureDesktop(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var entry atspi.Snapshot
|
|
var window atspi.ObjectID
|
|
root, err := tree.Root()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending := []atspi.ObjectReference{root}
|
|
for len(pending) > 0 {
|
|
reference := pending[len(pending)-1]
|
|
pending = pending[:len(pending)-1]
|
|
snapshot, err := tree.Resolve(reference)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snapshot.Name == "Command" {
|
|
entry = snapshot
|
|
}
|
|
if snapshot.Name == "Table presentation probe" {
|
|
window = snapshot.ID()
|
|
}
|
|
for child, ok, err := tree.FirstChild(reference); ; child, ok, err = tree.NextSibling(child) {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok {
|
|
break
|
|
}
|
|
pending = append(pending, child)
|
|
}
|
|
}
|
|
if !entry.ID().Valid() || !window.Valid() {
|
|
t.Fatal("launcher entry or window missing")
|
|
}
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 32), requests: make(chan speechRequest, 32)}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
state := &eventLoopState{}
|
|
state.presentationOptions = presentation.DefaultOptions()
|
|
state.focusedID, state.current = entry.ID(), entry
|
|
presenter := eventPresenter{state: state, logger: logger, speech: queue}
|
|
handler := accessibilityEventHandler{state: state, logger: logger}
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 64)}
|
|
worker := newSpeechWorker(logger, speaker)
|
|
worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading }
|
|
worker.verbosity = func() int { return 1 }
|
|
activation := atspi.NewEvent(atspi.WindowActivatedEvent, entry)
|
|
activation.Window, activation.WindowTitle = window, "Table presentation probe"
|
|
activation.WindowDescription = "Select an item and press Enter to open it."
|
|
presenter.speak(activation)
|
|
deliver := func() {
|
|
t.Helper()
|
|
for len(queue.requests) > 0 {
|
|
if err := worker.deliver(ctx, <-queue.requests); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
deliver()
|
|
for index, step := range []struct{ key, name string }{{"Down", "Alpha"}, {"Down", "Beta"}, {"shift+Tab", "Command"}} {
|
|
if err := exec.Command("xdotool", "key", step.key).Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
matched:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("no focus/selection for %s", step.name)
|
|
case event := <-adapter.Events():
|
|
if !isFocusGainEvent(event) && !isManagedDescendantEvent(event.Type) {
|
|
continue
|
|
}
|
|
if !handler.acceptFocusedEvent(event, input.Activity{Sequence: uint64(index + 1)}) {
|
|
presenter.speak(event)
|
|
}
|
|
deliver()
|
|
if event.Source.Name == step.name {
|
|
break matched
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var parts []string
|
|
for len(speaker.spoken) > 0 {
|
|
parts = append(parts, <-speaker.spoken)
|
|
}
|
|
text := strings.Join(parts, " | ")
|
|
t.Log(text)
|
|
if strings.Count(text, activation.WindowDescription) != 1 || strings.Contains(text, "Leaving ") ||
|
|
strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 1 {
|
|
t.Fatalf("unexpected launcher speech: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestGUIListTableNameWithoutBrowseContext(t *testing.T) {
|
|
for _, contextEnabled := range []bool{true, false} {
|
|
state := &eventLoopState{}
|
|
state.presentationOptions = presentation.DefaultOptions()
|
|
reading := config.DefaultSettings().Reading
|
|
reading.SpeakContextList = contextEnabled
|
|
state.presentationOptions.Reading = &reading
|
|
list := atspi.Snapshot{BusName: ":1.24", Path: "/results", Role: "list", Name: "Results"}
|
|
cell := atspi.Snapshot{BusName: list.BusName, Path: "/results/alpha", Name: "Alpha", Role: "table cell",
|
|
GUITable: true, TablePath: list.Path, TableName: list.Name, SpeechContext: []atspi.Snapshot{list}}
|
|
event := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell)
|
|
result := state.prepareGUITable(event, 1)
|
|
spoken, _ := state.decorateGUITable(event, presentation.Speech{Text: "Alpha"}, result)
|
|
want := "Results, Alpha"
|
|
if spoken.Text != want {
|
|
t.Fatalf("list context enabled=%t: got %q, want %q", contextEnabled, spoken.Text, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTableDescriptionAlreadyReadWithEntryActivation(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
|
state := &eventLoopState{}
|
|
state.presentationOptions = presentation.DefaultOptions()
|
|
presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue}
|
|
window := atspi.NewObjectID(":1.24", "/launcher")
|
|
entry := atspi.NewSnapshot("/launcher/entry", "Command", "text", "", "")
|
|
entry.BusName = window.BusName
|
|
event := atspi.NewEvent(atspi.WindowActivatedEvent, entry)
|
|
event.Window, event.WindowTitle = window, "Launcher"
|
|
event.WindowDescription = "Choose a result and press Enter."
|
|
presenter.speak(event)
|
|
if got := (<-queue.requests).Text; got != "Launcher, Command, text, Choose a result and press Enter." {
|
|
t.Fatalf("entry activation = %q", got)
|
|
}
|
|
cell := atspi.NewSnapshot("/launcher/results/alpha", "Alpha", "table cell", "", "")
|
|
cell.BusName = window.BusName
|
|
event.Type, event.Source = atspi.SelectionChangedEvent, cell
|
|
presenter.speak(event)
|
|
if got := (<-queue.requests).Text; got != "Alpha" {
|
|
t.Fatalf("first result repeated window description: %q", got)
|
|
}
|
|
// Changed help and help in another window must still be presented.
|
|
event.WindowDescription = "Updated result help."
|
|
presenter.speak(event)
|
|
if got := (<-queue.requests).Text; got != "Alpha, Updated result help." {
|
|
t.Fatalf("changed description = %q", got)
|
|
}
|
|
event.Window = atspi.NewObjectID(window.BusName, "/other")
|
|
event.Source.Path = "/other/results/alpha"
|
|
presenter.speak(event)
|
|
if got := (<-queue.requests).Text; got != "Alpha, Updated result help." {
|
|
t.Fatalf("other window description = %q", got)
|
|
}
|
|
// Opening with descriptions disabled must not consume help which becomes
|
|
// enabled before the user enters the results.
|
|
state.presentationOptions.SpeakDescription = false
|
|
event.Type, event.Source, event.Window = atspi.WindowActivatedEvent, entry, window
|
|
presenter.speak(event)
|
|
<-queue.requests
|
|
state.presentationOptions.SpeakDescription = true
|
|
event.Type, event.Source = atspi.SelectionChangedEvent, cell
|
|
presenter.speak(event)
|
|
if got := (<-queue.requests).Text; got != "Alpha, Updated result help." {
|
|
t.Fatalf("newly enabled description = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTableWindowDescriptionSurvivesImmediateTableFocus(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)}
|
|
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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.20", "/desktop")
|
|
cell := atspi.NewSnapshot("/desktop/table/item", "Alpha", "table cell", "", "")
|
|
cell.BusName = window.BusName
|
|
cell.TableName = "Desktop items"
|
|
cell.TableRowCount, cell.TableColumnCount = 2, 2
|
|
cell.TableRowText, cell.GUITable = "Alpha, Document", true
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell)
|
|
activated.Window = window
|
|
activated.WindowTitle = "Desktop"
|
|
activated.WindowDescription = "Select an item and press Enter to open it."
|
|
adapter.events <- activated
|
|
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
|
table.BusName = window.BusName
|
|
table.TableRowCount, table.TableColumnCount = 2, 2
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, table)
|
|
focused.Window = window
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
for index, expected := range []struct {
|
|
text string
|
|
append bool
|
|
}{
|
|
{"Desktop, Desktop items, table with 2 rows 2 columns, Alpha, Document, Select an item and press Enter to open it.", false},
|
|
{"Desktop items, table with 2 rows 2 columns", true},
|
|
} {
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != expected.text || request.Append != expected.append {
|
|
t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out waiting for request %d", index)
|
|
}
|
|
}
|
|
cancel()
|
|
<-completed
|
|
}
|
|
|
|
func TestTableDescriptionArrivingAfterActivation(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)}
|
|
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)
|
|
}()
|
|
window := atspi.NewObjectID(":1.21", "/desktop")
|
|
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
|
table.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, table)
|
|
activated.Window = window
|
|
activated.WindowTitle = "Desktop"
|
|
adapter.events <- activated
|
|
first := <-speech.requests
|
|
if first.Text != "Desktop, Desktop items, table" {
|
|
t.Fatalf("activation = %q", first.Text)
|
|
}
|
|
table.TableRowCount, table.TableColumnCount = 2, 2
|
|
focused := atspi.NewEvent(atspi.FocusedStateEvent, table)
|
|
focused.Window = window
|
|
focused.WindowDescription = "Select an item and press Enter to open it."
|
|
focused.Detail1 = 1
|
|
adapter.events <- focused
|
|
second := <-speech.requests
|
|
if second.Text != "Desktop items, table with 2 rows 2 columns, Select an item and press Enter to open it." {
|
|
t.Fatalf("focused table = %q", second.Text)
|
|
}
|
|
cell := atspi.NewSnapshot("/desktop/table/first", "Alpha", "table cell", "", "")
|
|
cell.BusName = window.BusName
|
|
cell.TablePath = table.Path
|
|
cell.TableRowText, cell.GUITable = "Alpha, Document", true
|
|
cell.TablePositionKnown = true
|
|
third := atspi.NewEvent(atspi.FocusedStateEvent, cell)
|
|
third.Window = window
|
|
third.Detail1 = 1
|
|
adapter.events <- third
|
|
request := <-speech.requests
|
|
if request.Text != "Alpha, Document" || !request.Append {
|
|
t.Fatalf("following cell = %q append %t", request.Text, request.Append)
|
|
}
|
|
cancel()
|
|
<-completed
|
|
}
|
|
|
|
func TestGUITableHorizontalMovementReadsOneCell(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
|
inputs := &sequencedInputActivity{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter,
|
|
nil, nil, 0, nil, inputs, nil, nil)
|
|
close(completed)
|
|
}()
|
|
window := atspi.NewObjectID(":1.22", "/desktop")
|
|
table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "")
|
|
table.BusName = window.BusName
|
|
activated := atspi.NewEvent(atspi.WindowActivatedEvent, table)
|
|
activated.Window = window
|
|
adapter.events <- activated
|
|
<-speech.requests
|
|
cellEvent := func(path, name, rowText string, row, column int, eventType string) atspi.Event {
|
|
cell := atspi.NewSnapshot(path, name, "table cell", "", "")
|
|
cell.BusName = window.BusName
|
|
cell.TablePath = table.Path
|
|
cell.TableRowIndex, cell.TableColumnIndex = row, column
|
|
cell.TablePositionKnown, cell.GUITable = true, true
|
|
cell.TableRowText = rowText
|
|
event := atspi.NewManagedDescendantEvent(eventType, table.Path, cell)
|
|
event.Window = window
|
|
return event
|
|
}
|
|
for _, step := range []struct {
|
|
sequence uint64
|
|
event atspi.Event
|
|
want string
|
|
}{
|
|
{1, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.SelectionChangedEvent), "Alpha, Document"},
|
|
{2, cellEvent("/desktop/table/document", "Document", "Alpha, Document", 0, 1, atspi.ActiveDescendantEvent), "Document"},
|
|
{3, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.ActiveDescendantEvent), "Alpha"},
|
|
{4, cellEvent("/desktop/table/beta", "Beta", "Beta, Shortcut", 1, 0, atspi.SelectionChangedEvent), "Beta, Shortcut"},
|
|
{4, cellEvent("/desktop/table/replacement", "Renamed", "Renamed, Shortcut", 1, 0, atspi.ActiveDescendantEvent), "Renamed"},
|
|
} {
|
|
inputs.set(step.sequence)
|
|
adapter.events <- step.event
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != step.want {
|
|
t.Fatalf("sequence %d = %q, want %q", step.sequence, request.Text, step.want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("sequence %d produced no speech", step.sequence)
|
|
}
|
|
}
|
|
cancel()
|
|
<-completed
|
|
}
|
|
|
|
func TestGUITableReusedCurrentCellReadsReplacementRow(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
completed := make(chan struct{})
|
|
go func() {
|
|
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
|
close(completed)
|
|
}()
|
|
defer func() { cancel(); <-completed }()
|
|
|
|
cell := atspi.NewSnapshot("/mail/messages/row", "First subject", "table cell", "", "")
|
|
cell.BusName = ":1.23"
|
|
cell.TablePath = "/mail/messages"
|
|
cell.TableRowIndex, cell.TableColumnIndex = 0, 0
|
|
cell.TablePositionKnown, cell.GUITable = true, true
|
|
cell.TableRowText = "First subject, First sender"
|
|
focus := atspi.NewEvent(atspi.FocusedStateEvent, cell)
|
|
focus.Detail1 = 1
|
|
adapter.events <- focus
|
|
if got := (<-speech.requests).Text; got != cell.TableRowText {
|
|
t.Fatalf("initial row = %q", got)
|
|
}
|
|
|
|
replacement := cell
|
|
replacement.Name = "Next subject"
|
|
replacement.TableRowText = "Next subject, Next sender"
|
|
selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, cell.TablePath, replacement)
|
|
adapter.events <- selected
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != replacement.TableRowText {
|
|
t.Fatalf("replacement row = %q, want %q", request.Text, replacement.TableRowText)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement row was not spoken")
|
|
}
|
|
|
|
// A name event is another way a provider can expose a reused row.
|
|
third := replacement
|
|
third.Name = "Third subject"
|
|
third.TableRowText = "Third subject, Third sender"
|
|
nameChanged := atspi.NewEvent(atspi.AccessibleNameEvent, third)
|
|
adapter.events <- nameChanged
|
|
select {
|
|
case request := <-speech.requests:
|
|
if request.Text != third.TableRowText {
|
|
t.Fatalf("renamed row = %q, want %q", request.Text, third.TableRowText)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("renamed current row was not spoken")
|
|
}
|
|
}
|