Files
cthulhu/internal/app/runtime_commands_test.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")
}
})
}
}