Files

472 lines
13 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
package speech
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
"time"
)
func TestSpeakSendsSSIPAndAcceptsQueuedReply(t *testing.T) {
connection, done := startSSIPServer(t, func(reader *bufio.Reader) error {
request, err := reader.ReadString('\n')
if err != nil {
return err
}
if request != "SPEAK\r\n" {
return errors.New("missing SPEAK command")
}
text, err := reader.ReadString('\n')
if err != nil {
return err
}
if text != "Generic focus text\r\n" {
return fmt.Errorf("speech text = %q", text)
}
if terminator, err := reader.ReadString('\n'); err != nil || terminator != ".\r\n" {
if err != nil {
return err
}
return errors.New("invalid speech block terminator")
}
return nil
}, "230 OK RECEIVING DATA\r\n225-1\r\n225 OK MESSAGE QUEUED\r\n")
client := connectedClient(connection)
if err := client.Speak(context.Background(), "Generic focus text"); err != nil {
t.Fatalf("Speak: %v", err)
}
if err := client.Close(context.Background()); err != nil {
t.Fatalf("Close: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("SSIP server error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for fake SSIP server")
}
}
func TestSpeakCharacterSendsEachRepeatedCharacter(t *testing.T) {
connection, server := net.Pipe()
client := connectedClient(connection)
defer client.Close(context.Background())
defer server.Close()
done := make(chan error, 1)
characters := []string{"o", "o", "1", "1", "1", "l", "l"}
go func() {
reader := bufio.NewReader(server)
for _, character := range characters {
if err := exchangeSSIP(reader, server, "CHAR "+character+"\r\n", "225 OK MESSAGE QUEUED\r\n"); err != nil {
done <- err
return
}
}
done <- nil
}()
for _, character := range characters {
if err := client.SpeakCharacter(context.Background(), character); err != nil {
t.Fatal(err)
}
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestCancelSendsSSIPCommand(t *testing.T) {
connection, done := startSSIPServer(t, func(reader *bufio.Reader) error {
request, err := reader.ReadString('\n')
if err != nil {
return err
}
if request != "CANCEL 42\r\n" {
return errors.New("unexpected cancel command")
}
return nil
}, "200 OK CANCELLED\r\n")
client := New("")
client.cancelConn = connection
client.cancelReader = bufio.NewReader(connection)
client.clientID = 42
if err := client.Cancel(context.Background()); err != nil {
t.Fatalf("Cancel: %v", err)
}
if err := client.Close(context.Background()); err != nil {
t.Fatalf("Close: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("SSIP server error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for fake SSIP server")
}
}
func TestApplySettingsSendsConfiguredSSIPCommands(t *testing.T) {
rate, pitch, volume := 52, 0, 40
connection, serverConnection := net.Pipe()
client := connectedClient(connection)
client.settings = Settings{
Rate: &rate,
Pitch: &pitch,
Volume: &volume,
Language: "en-US",
SynthesisVoice: "Generic Voice",
Punctuation: "some",
Capitalization: "spell",
}
expected := []string{
"SET SELF CLIENT_NAME cthulhu:cthulhu:main\r\n",
"SET SELF PRIORITY MESSAGE\r\n",
"SET SELF PUNCTUATION some\r\n",
"SET SELF CAP_LET_RECOGN spell\r\n",
"SET SELF RATE 52\r\n",
"SET SELF PITCH 0\r\n",
"SET SELF VOLUME 40\r\n",
"SET SELF LANGUAGE en-US\r\n",
"SET SELF SYNTHESIS_VOICE Generic Voice\r\n",
}
done := make(chan error, 1)
go func() {
reader := bufio.NewReader(serverConnection)
for _, want := range expected {
got, err := reader.ReadString('\n')
if err != nil {
done <- err
return
}
if got != want {
done <- fmt.Errorf("SSIP command = %q, want %q", got, want)
return
}
if _, err := serverConnection.Write([]byte("200 OK\r\n")); err != nil {
done <- err
return
}
}
done <- nil
}()
t.Cleanup(func() { serverConnection.Close() })
if err := client.applySettings(context.Background()); err != nil {
t.Fatalf("applySettings: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for configured SSIP commands")
}
}
func TestNormalizeSpeakText(t *testing.T) {
for _, test := range []struct {
name, input, want string
}{
{"line breaks", " Generic\r\nlabel ", "Generic label"},
{"punctuation", ".", "."},
{"tabs and spacing", "\tRoom 123\tA12\n", "Room 123 A12"},
{"Unicode whitespace", "\u00a0Café\u2003猫\u2028text\u3000", "Café 猫 text"},
{"combining characters", "\te\u0301 saveFile!\n", "e\u0301 saveFile!"},
{"invalid UTF-8", "\x80bad\xfftext", "�bad�text"},
{"empty", "", ""},
{"whitespace only", " \r\n\t\u00a0\u2003", ""},
} {
t.Run(test.name, func(t *testing.T) {
text, err := normalizeSpeakText(test.input)
if text != test.want || (err != nil) != (test.want == "") {
t.Fatalf("normalizeSpeakText(%q) = %q, %v; want %q", test.input, text, err, test.want)
}
if err != nil && err.Error() != "speech text is empty" {
t.Fatalf("empty speech error = %v", err)
}
})
}
}
func FuzzNormalizeSpeakTextPreservesOutput(f *testing.F) {
for _, text := range []string{"", " Generic\r\nlabel ", ".", "\tRoom 123 A12", "Café\u00a0猫\u2003text", "e\u0301 saveFile!", "\x80bad\xfftext", " \r\n\t\u00a0\u2003", "text\x00with\vcontrols"} {
f.Add(text)
}
f.Fuzz(func(t *testing.T, text string) {
// Retain the previous normalization pipeline as a behavior oracle.
expected := strings.ToValidUTF8(text, "�")
expected = strings.NewReplacer("\r", " ", "\n", " ").Replace(expected)
expected = strings.TrimSpace(strings.Join(strings.Fields(expected), " "))
actual, err := normalizeSpeakText(text)
if actual != expected || (err != nil) != (expected == "") {
t.Fatalf("normalization changed for %q: got %q, %v; want %q", text, actual, err, expected)
}
})
}
func BenchmarkNormalizeSpeakText(b *testing.B) {
for _, sample := range []struct{ name, text string }{
{"short", "Save button"},
{"whitespace", " Example\r\nline\twith multiple spaces. "},
} {
b.Run(sample.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := normalizeSpeakText(sample.text); err != nil {
b.Fatal(err)
}
}
})
}
}
func startSSIPServer(
t *testing.T,
readRequest func(*bufio.Reader) error,
reply string,
) (net.Conn, chan error) {
t.Helper()
clientConnection, serverConnection := net.Pipe()
done := make(chan error, 1)
go func() {
defer serverConnection.Close()
reader := bufio.NewReader(serverConnection)
if err := readRequest(reader); err != nil {
done <- err
return
}
if _, err := serverConnection.Write([]byte(reply)); err != nil {
done <- err
return
}
done <- nil
}()
t.Cleanup(func() {
clientConnection.Close()
serverConnection.Close()
})
return clientConnection, done
}
func connectedClient(connection net.Conn) *Client {
client := New("")
client.conn = connection
client.reader = bufio.NewReader(connection)
return client
}
func TestInitializeTargetsSpeakingConnection(t *testing.T) {
for _, test := range []struct {
name string
reply string
valid bool
}{
{"valid", "245-42\r\n245 OK CLIENT ID SENT\r\n", true},
{"missing", "245 OK\r\n", false},
{"not numeric", "245-SELF\r\n245 OK\r\n", false},
{"zero", "245-0\r\n245 OK\r\n", false},
{"negative", "245--1\r\n245 OK\r\n", false},
{"wrong code", "200-42\r\n200 OK\r\n", false},
{"rejected", "500 UNKNOWN COMMAND\r\n", false},
{"multiple", "245-42\r\n245-43\r\n245 OK\r\n", false},
} {
t.Run(test.name, func(t *testing.T) {
mainConn, mainServer := net.Pipe()
controlConn, controlServer := net.Pipe()
client := connectedClient(mainConn)
client.cancelConn, client.cancelReader = controlConn, bufio.NewReader(controlConn)
t.Cleanup(func() {
mainServer.Close()
controlServer.Close()
client.Close(context.Background())
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
reader := bufio.NewReader(mainServer)
for _, exchange := range []struct{ command, reply string }{
{"SET SELF CLIENT_NAME cthulhu:cthulhu:main\r\n", "200 OK\r\n"},
{"SET SELF PRIORITY MESSAGE\r\n", "200 OK\r\n"},
{"HISTORY GET CLIENT_ID\r\n", test.reply},
} {
if err := exchangeSSIP(reader, mainServer, exchange.command, exchange.reply); err != nil {
done <- err
return
}
}
if test.valid {
reader = bufio.NewReader(controlServer)
for _, command := range []string{
"SET SELF CLIENT_NAME cthulhu:cthulhu:cancel\r\n",
"CANCEL 42\r\n",
} {
if err := exchangeSSIP(reader, controlServer, command, "200 OK\r\n"); err != nil {
done <- err
return
}
}
}
done <- nil
}()
err := client.initialize(ctx)
if (err == nil) != test.valid {
t.Fatalf("initialize error = %v, valid = %v", err, test.valid)
}
if test.valid {
if err := client.Cancel(ctx); err != nil {
t.Fatal(err)
}
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
})
}
}
func exchangeSSIP(reader *bufio.Reader, conn net.Conn, command, reply string) error {
got, err := reader.ReadString('\n')
if err != nil {
return err
}
if got != command {
return fmt.Errorf("command = %q, want %q", got, command)
}
_, err = conn.Write([]byte(reply))
return err
}
func TestCancelWhileSpeakWaitsForReply(t *testing.T) {
mainConn, mainServer := net.Pipe()
controlConn, controlDone := startSSIPServer(t, func(reader *bufio.Reader) error {
got, err := reader.ReadString('\n')
if err == nil && got != "CANCEL 42\r\n" {
return fmt.Errorf("cancel = %q", got)
}
return err
}, "200 OK\r\n")
client := connectedClient(mainConn)
client.cancelConn, client.cancelReader, client.clientID = controlConn, bufio.NewReader(controlConn), 42
t.Cleanup(func() { mainServer.Close(); client.Close(context.Background()) })
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
speakDone := make(chan error, 1)
go func() { speakDone <- client.Speak(ctx, "Generic text") }()
reader := bufio.NewReader(mainServer)
for range 3 {
if _, err := reader.ReadString('\n'); err != nil {
t.Fatal(err)
}
}
if err := client.Cancel(ctx); err != nil {
t.Fatalf("Cancel during SPEAK: %v", err)
}
if err := <-controlDone; err != nil {
t.Fatal(err)
}
cancel()
select {
case err := <-speakDone:
if err == nil {
t.Fatal("blocked Speak succeeded")
}
case <-time.After(time.Second):
t.Fatal("Speak ignored context cancellation")
}
if err := client.Speak(context.Background(), "Next"); err == nil {
t.Fatal("reused interrupted protocol exchange")
}
}
func TestCancelContextAndFailedChannel(t *testing.T) {
conn, server := net.Pipe()
client := New("")
client.cancelConn, client.cancelReader, client.clientID = conn, bufio.NewReader(conn), 42
t.Cleanup(func() { server.Close(); client.Close(context.Background()) })
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := client.Cancel(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("Cancel = %v", err)
}
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- client.Cancel(ctx) }()
if _, err := bufio.NewReader(server).ReadString('\n'); err != nil {
t.Fatal(err)
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("Cancel succeeded without reply")
}
case <-time.After(time.Second):
t.Fatal("Cancel ignored context cancellation")
}
if err := client.Cancel(context.Background()); err == nil {
t.Fatal("reused failed control channel")
}
}
func TestSpeakEscapesLeadingDots(t *testing.T) {
for _, text := range []string{".", "..", ".hidden", "ordinary"} {
t.Run(text, func(t *testing.T) {
wireText := text
if text[0] == '.' {
wireText = "." + text
}
conn, done := startSSIPServer(t, func(reader *bufio.Reader) error {
for _, want := range []string{"SPEAK\r\n", wireText + "\r\n", ".\r\n"} {
got, err := reader.ReadString('\n')
if err != nil {
return err
}
if got != want {
return fmt.Errorf("wire text = %q, want %q", got, want)
}
}
return nil
}, "230 OK RECEIVING DATA\r\n225-1\r\n225 OK MESSAGE QUEUED\r\n")
client := connectedClient(conn)
if err := client.Speak(context.Background(), text); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
client.Close(context.Background())
})
}
}