// 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 . 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()) }) } }