Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,258 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user