// 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" "errors" "log/slog" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" ) func TestRandomString(t *testing.T) { if got := random_string(nil); got != "" { t.Fatalf("random_string(nil) = %q, want empty string", got) } if got := random_string([]string{"only"}); got != "only" { t.Fatalf("random_string(single item) = %q, want %q", got, "only") } } func TestRunStopsSpeechAfterInputStartupFailure(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} inputListener := &fakeInputListener{ events: make(chan input.Event), modifierKeycodes: input.NewModifierKeycodes(37), startError: errors.New("no X display"), } err := Run(context.Background(), Options{ Logger: slog.New(slog.DiscardHandler), newSpeaker: func() Speaker { return speaker }, newCommands: func() commandListener { return silentCommands{} }, newSound: func() soundService { return silentSound{} }, newAdapter: func() Adapter { return adapter }, newInput: func() InputListener { return inputListener }, }) if err == nil || !strings.Contains(err.Error(), "no X display") { t.Fatalf("Run error = %v, want input startup failure", err) } if adapter.started { t.Fatal("AT-SPI started after X11 listener startup failure") } if !speaker.closed { t.Fatal("speech connection was not closed after X11 listener startup failure") } } func TestRunStopsStartedServicesAfterStartupFailure(t *testing.T) { speaker := &fakeSpeaker{connectError: errors.New("connection refused"), spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} err := Run(context.Background(), Options{ Logger: slog.New(slog.DiscardHandler), newSpeaker: func() Speaker { return speaker }, newCommands: func() commandListener { return silentCommands{} }, newSound: func() soundService { return silentSound{} }, newAdapter: func() Adapter { return adapter }, }) if err == nil { t.Fatal("Run returned nil after speech connection failure") } if adapter.started { t.Fatal("AT-SPI started despite speech startup failure") } } func TestRunHonorsDisabledSpeech(t *testing.T) { settings := config.DefaultSettings() settings.EnableSpeech = false adapter := &fakeAdapter{events: make(chan atspi.Event)} close(adapter.events) speakerCreated := false speaker := &fakeSpeaker{spoken: make(chan string, 1)} err := Run(context.Background(), Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, newSpeaker: func() Speaker { speakerCreated = true return speaker }, newCommands: func() commandListener { return silentCommands{} }, newSound: func() soundService { return silentSound{} }, newAdapter: func() Adapter { return adapter }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, }) if err != nil { t.Fatalf("Run: %v", err) } if !speakerCreated { t.Fatal("speech client was not available for live re-enabling") } if speaker.connected { t.Fatal("speech backend connected while speech was disabled") } if !adapter.started || !adapter.stopped { t.Fatal("AT-SPI lifecycle did not run while speech was disabled") } } func TestRunOpensPreferencesAtStartup(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, 1)} preferencesCalled := make(chan bool, 1) completed := make(chan error, 1) go func() { completed <- Run(context.Background(), Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, OpenPreferences: true, newCommands: func() commandListener { return commands }, onPreferences: func() { select { case preferencesCalled <- adapter.started: default: } }, newAdapter: func() Adapter { return adapter }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, }) }() select { case adapterStarted := <-preferencesCalled: if !adapterStarted { t.Fatal("preferences were requested before runtime services started") } case <-time.After(time.Second): t.Fatal("startup 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 startup preferences") } } func TestRunReachesSoundStopAfterEarlierServiceStopError(t *testing.T) { settings := config.DefaultSettings() settings.EnableSpeech = false adapter := &fakeAdapter{ events: make(chan atspi.Event, 1), stopError: context.DeadlineExceeded, } sounds := &recordingSound{started: make(chan struct{})} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan error, 1) go func() { completed <- Run(ctx, Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, newSound: func() soundService { return sounds }, newCommands: func() commandListener { return silentCommands{} }, newAdapter: func() Adapter { return adapter }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, }) }() select { case <-sounds.started: case <-time.After(time.Second): t.Fatal("sound service did not start") } cancel() select { case err := <-completed: if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Run error = %v, want deadline exceeded", err) } case <-time.After(time.Second): t.Fatal("timed out waiting for Run to stop") } if !adapter.stopped { t.Fatal("earlier service was not stopped") } if !sounds.stopped { t.Fatal("final sound service was not reached") } } func TestRunReportsSoundStopError(t *testing.T) { settings := config.DefaultSettings() settings.EnableSpeech = false soundError := errors.New("stop cue failed") sounds := &recordingSound{started: make(chan struct{}), stopError: soundError} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan error, 1) go func() { completed <- Run(ctx, Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, newSound: func() soundService { return sounds }, newCommands: func() commandListener { return silentCommands{} }, newAdapter: func() Adapter { return &fakeAdapter{events: make(chan atspi.Event, 1)} }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, }) }() select { case <-sounds.started: case <-time.After(time.Second): t.Fatal("sound service did not start") } cancel() select { case err := <-completed: if !errors.Is(err, soundError) { t.Fatalf("Run error = %v, want sound stop error", err) } case <-time.After(time.Second): t.Fatal("timed out waiting for Run to stop") } }