472 lines
13 KiB
Go
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())
|
|
})
|
|
}
|
|
}
|