// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "bufio" "context" "fmt" "net" "path/filepath" "strings" "sync" "testing" "time" ) // Each connection gets its own client ID and command history, allowing tests // to detect settings sent to the old connection or cancellation of the wrong ID. type settingsServer struct { mu sync.Mutex commands map[int][]string connections []net.Conn hook func(string) string } func newSettingsServer(t *testing.T, hook func(string) string) (string, *settingsServer) { t.Helper() path := filepath.Join(t.TempDir(), "speech.sock") listener, err := net.Listen("unix", path) if err != nil { t.Fatal(err) } return path, newSettingsServerListener(t, listener, hook) } func newSettingsServerListener(t *testing.T, listener net.Listener, hook func(string) string) *settingsServer { t.Helper() server := &settingsServer{commands: make(map[int][]string), hook: hook} var handlers sync.WaitGroup accepted := make(chan struct{}) go func() { defer close(accepted) for id := 1; ; id++ { conn, err := listener.Accept() if err != nil { return } server.mu.Lock() server.connections = append(server.connections, conn) server.mu.Unlock() handlers.Add(1) go func(id int) { defer handlers.Done() defer conn.Close() reader := bufio.NewReader(conn) for { line, err := reader.ReadString('\n') if err != nil { return } command := strings.TrimSpace(line) server.mu.Lock() server.commands[id] = append(server.commands[id], command) server.mu.Unlock() response := "200 OK\r\n" if command == "HISTORY GET CLIENT_ID" { response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id) } if command == "SPEAK" { for { text, err := reader.ReadString('\n') if err != nil { return } if text == ".\r\n" { break } } response = "230 OK\r\n200 QUEUED\r\n" } if server.hook != nil { if override := server.hook(command); override != "" { response = override } } if _, err := conn.Write([]byte(response)); err != nil { return } } }(id) } }() t.Cleanup(func() { listener.Close() <-accepted server.mu.Lock() for _, conn := range server.connections { conn.Close() } server.mu.Unlock() handlers.Wait() }) return server } func (server *settingsServer) saw(id int, command string) bool { server.mu.Lock() defer server.mu.Unlock() for _, got := range server.commands[id] { if got == command { return true } } return false } func TestUpdateSettingsSwitchesSpeechAndCancellation(t *testing.T) { path, server := newSettingsServer(t, nil) rate := 20 client := NewWithSettings(path, Settings{Rate: &rate}) rate = 80 // The constructor must own its settings. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() defer client.Close(ctx) if err := client.Connect(ctx); err != nil { t.Fatal(err) } if !server.saw(1, "SET SELF RATE 20") { t.Fatal("constructor retained caller's rate pointer") } if err := client.UpdateSettings(ctx, Settings{Punctuation: "all"}); err != nil { t.Fatal(err) } if err := client.Speak(ctx, "new settings"); err != nil { t.Fatal(err) } if err := client.Cancel(ctx); err != nil { t.Fatal(err) } if !server.saw(3, "SET SELF PUNCTUATION all") || !server.saw(3, "SPEAK") { t.Fatal("new settings and speech did not use replacement connection") } if server.saw(3, "SET SELF RATE 20") || client.settings.Rate != nil { t.Fatal("omitted rate did not return to fresh-connection defaults") } if !server.saw(2, "CANCEL 1") || !server.saw(4, "CANCEL 3") { t.Fatal("cancellation did not follow the speaking connection") } } func TestUpdateSettingsRejectionPreservesActiveClient(t *testing.T) { path, server := newSettingsServer(t, func(command string) string { if command == "SET SELF PITCH 40" { return "500 REJECTED\r\n" } return "" }) client := New(path) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() defer client.Close(ctx) if err := client.Connect(ctx); err != nil { t.Fatal(err) } rate, pitch := 25, 40 if err := client.UpdateSettings(ctx, Settings{Rate: &rate, Pitch: &pitch}); err == nil { t.Fatal("rejected update succeeded") } if err := client.Speak(ctx, "still speaking"); err != nil { t.Fatal(err) } if server.saw(2, "CANCEL 1") || server.saw(1, "SET SELF RATE 25") || !server.saw(1, "SPEAK") { t.Fatal("rejected candidate affected active speech") } if client.settings.Rate != nil { t.Fatal("rejected settings were committed") } if err := client.Cancel(ctx); err != nil { t.Fatal(err) } } func TestUpdateSettingsPreparationDoesNotBlockSpeechOrReviveClosedClient(t *testing.T) { preparing, release := make(chan struct{}), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } path, _ := newSettingsServer(t, func(command string) string { if command == "SET SELF RATE 30" { close(preparing) <-release } return "" }) defer unblock() client := New(path) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() defer client.Close(ctx) if err := client.Connect(ctx); err != nil { t.Fatal(err) } done := make(chan error, 1) go func() { rate := 30; done <- client.UpdateSettings(ctx, Settings{Rate: &rate}) }() select { case <-preparing: case <-ctx.Done(): t.Fatal(ctx.Err()) } if err := client.UpdateSettings(ctx, Settings{}); err == nil || !strings.Contains(err.Error(), "already in progress") { t.Fatalf("overlapping update = %v, want busy error", err) } if err := client.Speak(ctx, "while preparing"); err != nil { t.Fatal(err) } if err := client.Cancel(ctx); err != nil { t.Fatal(err) } if err := client.Close(ctx); err != nil { t.Fatal(err) } unblock() select { case err := <-done: if err == nil { t.Fatal("update revived closed client") } case <-ctx.Done(): t.Fatal(ctx.Err()) } if client.conn != nil || client.cancelConn != nil { t.Fatal("closed client regained connections") } } func TestUpdateSettingsCanceledContext(t *testing.T) { path, server := newSettingsServer(t, nil) client := New(path) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() defer client.Close(ctx) if err := client.Connect(ctx); err != nil { t.Fatal(err) } canceled, stop := context.WithCancel(ctx) stop() if err := client.UpdateSettings(canceled, Settings{}); err == nil { t.Fatal("canceled update succeeded") } if err := client.Speak(ctx, "unchanged"); err != nil { t.Fatal(err) } if server.saw(2, "CANCEL 1") { t.Fatal("canceled update interrupted speech") } }