259 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|