// 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 . 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.PresentCurrentLine, input.SpellCurrentLine, input.PhoneticCurrentLine, input.WhereAmI, input.PresentWindowTitle, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn} { 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") } }) } }