// 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/input" "git.stormux.org/storm/cthulhu/internal/sound" ) func TestRunSpeaksFocusEventsAndStops(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} inputListener := &fakeInputListener{ events: make(chan input.Event, 1), modifierKeycodes: input.NewModifierKeycodes(37), } ctx, cancel := context.WithCancel(context.Background()) completed := make(chan error, 1) go func() { completed <- Run(ctx, 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 }, }) }() adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) for index, expected := range []string{"", "Save", "push button"} { select { case text := <-speaker.spoken: if index == 0 { if !contains_string(startupMessages, text) { t.Fatalf("startup speech = %q, want one of %q", text, startupMessages) } continue } if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for %q", expected) } } cancel() select { case err := <-completed: if err != nil { t.Fatalf("Run: %v", err) } case <-time.After(time.Second): t.Fatal("timed out waiting for Run to stop") } select { case text := <-speaker.spoken: if !contains_string(shutdownMessages, text) { t.Fatalf("shutdown speech = %q, want one of %q", text, shutdownMessages) } default: t.Fatal("shutdown message was not spoken") } if !adapter.stopped || !speaker.closed || !speaker.canceledOnClose { t.Fatal("runtime did not stop both services") } if !inputListener.started || !inputListener.stopped { t.Fatal("runtime did not run the X11 listener lifecycle") } } func (silentSound) Start(context.Context) error { return nil } func (silentSound) Stop(context.Context) error { return nil } func (silentSound) Enqueue(sound.Cue) bool { return true } func (silentSound) EnqueueBatch([]sound.Cue) bool { return true } func (silentSound) Available(string) bool { return true } func (rejectingSound) Enqueue(sound.Cue) bool { return false } func (rejectingSound) EnqueueBatch([]sound.Cue) bool { return false } func (silentCommands) Commands() <-chan input.CommandEvent { return nil } func (silentCommands) LockStates() <-chan input.LockState { return nil } func (silentCommands) Failures() <-chan error { return nil } func (silentCommands) Start(context.Context) error { return nil } func (silentCommands) Stop(context.Context) error { return nil }