Files

259 lines
6.8 KiB
Go

// 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")
}
}