350 lines
11 KiB
Go
350 lines
11 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/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
)
|
|
|
|
func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 1)}
|
|
requests := make(chan input.Command, 1)
|
|
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands,
|
|
reviewRequests: requests, stopRun: cancel}
|
|
done := make(chan error, 1)
|
|
go func() { done <- worker.run(ctx) }()
|
|
defer func() {
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Error("command worker did not stop")
|
|
}
|
|
}()
|
|
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula} {
|
|
commands.commands <- input.CommandEvent{Command: command}
|
|
select {
|
|
case got := <-requests:
|
|
if got != command {
|
|
t.Fatalf("focus request = %q, want %q", got, command)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing focus request %q", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRoutesPreferencesWithoutStoppingCommandHandling(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSpeech = false
|
|
settings.EnableSound = false
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)}
|
|
preferencesCalled := make(chan struct{}, 1)
|
|
completed := make(chan error, 1)
|
|
|
|
go func() {
|
|
completed <- Run(context.Background(), Options{
|
|
Logger: slog.New(slog.DiscardHandler),
|
|
Settings: &settings,
|
|
newCommands: func() commandListener { return commands },
|
|
onPreferences: func() { preferencesCalled <- struct{}{} },
|
|
newAdapter: func() Adapter { return adapter },
|
|
newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} },
|
|
})
|
|
}()
|
|
|
|
commands.commands <- input.CommandEvent{Command: input.Preferences}
|
|
select {
|
|
case <-preferencesCalled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("preferences callback was not invoked")
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.Quit}
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("quit was not handled after preferences")
|
|
}
|
|
}
|
|
|
|
func TestRunRoutesShowNotificationList(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSpeech = false
|
|
settings.EnableSound = false
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)}
|
|
listCalled := make(chan struct{}, 1)
|
|
completed := make(chan error, 1)
|
|
|
|
go func() {
|
|
completed <- Run(context.Background(), Options{
|
|
Logger: slog.New(slog.DiscardHandler), Settings: &settings,
|
|
newCommands: func() commandListener { return commands },
|
|
onNotificationList: func() { listCalled <- struct{}{} },
|
|
newAdapter: func() Adapter { return adapter },
|
|
newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} },
|
|
})
|
|
}()
|
|
|
|
commands.commands <- input.CommandEvent{Command: input.ShowNotificationList}
|
|
select {
|
|
case <-listCalled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("notification list callback was not invoked")
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.Quit}
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("quit was not handled after opening the notification list")
|
|
}
|
|
}
|
|
|
|
func TestRunRoutesNotificationCommands(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSpeech = true
|
|
settings.EnableSound = false
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 4)}
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)}
|
|
listCalled := make(chan struct{}, 1)
|
|
completed := make(chan error, 1)
|
|
|
|
go func() {
|
|
completed <- Run(context.Background(), Options{
|
|
Logger: slog.New(slog.DiscardHandler), Settings: &settings,
|
|
newCommands: func() commandListener { return commands },
|
|
newSpeaker: func() Speaker { return speaker },
|
|
newAdapter: func() Adapter { return adapter },
|
|
newInput: func() InputListener {
|
|
return &fakeInputListener{
|
|
events: make(chan input.Event),
|
|
modifierKeycodes: input.NewModifierKeycodes(37),
|
|
}
|
|
},
|
|
onNotificationList: func() {
|
|
select {
|
|
case listCalled <- struct{}{}:
|
|
default:
|
|
}
|
|
},
|
|
})
|
|
}()
|
|
|
|
select {
|
|
case message := <-speaker.spoken:
|
|
if !contains_string(startupMessages, message) {
|
|
t.Fatalf("startup speech = %q, want one of %q", message, startupMessages)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("startup speech was not presented")
|
|
}
|
|
|
|
commands.commands <- input.CommandEvent{Command: input.PresentLastNotification}
|
|
select {
|
|
case message := <-speaker.spoken:
|
|
if message != "No notification messages" {
|
|
t.Fatalf("last notification speech = %q, want %q", message, "No notification messages")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("last notification speech was not presented")
|
|
}
|
|
|
|
commands.commands <- input.CommandEvent{Command: input.ShowNotificationList}
|
|
select {
|
|
case <-listCalled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("notification list callback was not invoked")
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.Quit}
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("quit was not handled after notification commands")
|
|
}
|
|
}
|
|
|
|
func TestRunCommandLoopRemainsResponsiveDuringObjectCapture(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSpeech = true
|
|
settings.EnableSound = false
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 3)}
|
|
inputListener := &fakeInputListener{
|
|
events: make(chan input.Event),
|
|
modifierKeycodes: input.NewModifierKeycodes(37),
|
|
}
|
|
captureStarted := make(chan struct{})
|
|
adapter := &fakeAdapter{
|
|
events: make(chan atspi.Event, 1),
|
|
capture: func(ctx context.Context) (*atspi.AccessibleTree, error) {
|
|
close(captureStarted)
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
}
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)}
|
|
completed := make(chan error, 1)
|
|
|
|
go func() {
|
|
completed <- Run(context.Background(), Options{
|
|
Logger: slog.New(slog.DiscardHandler),
|
|
Settings: &settings,
|
|
newSpeaker: func() Speaker { return speaker },
|
|
newInput: func() InputListener { return inputListener },
|
|
newCommands: func() commandListener { return commands },
|
|
onPreferences: func() {},
|
|
newAdapter: func() Adapter { return adapter },
|
|
})
|
|
}()
|
|
select {
|
|
case <-speaker.spoken:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("startup speech was not delivered")
|
|
}
|
|
focus := atspi.NewSnapshot("/focused", "Focused", "push button", "", "")
|
|
focus.BusName = ":1.42"
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, focus)
|
|
for _, want := range []string{"Focused", "push button"} {
|
|
select {
|
|
case text := <-speaker.spoken:
|
|
if text != want {
|
|
t.Fatalf("focus speech = %q, want %q", text, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("focus was not delivered before object review command")
|
|
}
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.ObjectNextSibling}
|
|
select {
|
|
case <-captureStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("object review capture did not start")
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.Quit}
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("quit command was blocked by object review capture")
|
|
}
|
|
if !adapter.stopped {
|
|
t.Fatal("adapter did not stop after capture cancellation")
|
|
}
|
|
}
|
|
|
|
type attributeFakeAdapter struct {
|
|
*fakeAdapter
|
|
values map[string]string
|
|
}
|
|
|
|
func (adapter *attributeFakeAdapter) ReadCharacterAttributes(_ context.Context, object atspi.ObjectID) (atspi.CharacterAttributes, error) {
|
|
return atspi.CharacterAttributes{Object: object, Values: adapter.values}, nil
|
|
}
|
|
|
|
func TestRunPresentsCurrentCharacterColorNames(t *testing.T) {
|
|
for _, probe := range []struct {
|
|
names bool
|
|
want string
|
|
}{{true, "foreground color: red"}, {false, "foreground color: 255 0 0"}} {
|
|
t.Run(probe.want, func(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSound = false
|
|
settings.EnableBraille = false
|
|
settings.Reading.UseColorNames = probe.names
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 4)}
|
|
adapter := &attributeFakeAdapter{
|
|
fakeAdapter: &fakeAdapter{events: make(chan atspi.Event, 1)},
|
|
values: map[string]string{"fg-color": "rgb(65535,0,0)"},
|
|
}
|
|
commands := &fakeCommands{commands: make(chan input.CommandEvent, 2)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
completed := make(chan error, 1)
|
|
go func() {
|
|
completed <- Run(ctx, Options{
|
|
Logger: slog.New(slog.DiscardHandler), Settings: &settings,
|
|
newSpeaker: func() Speaker { return speaker },
|
|
newAdapter: func() Adapter { return adapter },
|
|
newCommands: func() commandListener { return commands },
|
|
newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} },
|
|
})
|
|
}()
|
|
defer func() {
|
|
cancel()
|
|
select {
|
|
case err := <-completed:
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("reader did not stop")
|
|
}
|
|
}()
|
|
select {
|
|
case <-speaker.spoken:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("startup speech missing")
|
|
}
|
|
focus := atspi.NewSnapshot("/entry", "Color entry", "entry", "", "")
|
|
focus.BusName = ":1.42"
|
|
adapter.events <- atspi.NewEvent(atspi.FocusEvent, focus)
|
|
for _, want := range []string{"Color entry", "entry"} {
|
|
select {
|
|
case got := <-speaker.spoken:
|
|
if got != want {
|
|
t.Fatalf("focus speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("focus speech missing")
|
|
}
|
|
}
|
|
commands.commands <- input.CommandEvent{Command: input.PresentCharacterAttributes}
|
|
select {
|
|
case spoken := <-speaker.spoken:
|
|
if spoken != probe.want {
|
|
t.Fatalf("character attributes = %q, want %q", spoken, probe.want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("character attribute command did not speak")
|
|
}
|
|
})
|
|
}
|
|
}
|