// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "bufio" "fmt" "reflect" "strings" "testing" ) func TestLargeVoiceCatalogAndSilentPreparation(t *testing.T) { server := new_fake_server(t, false) server.mu.Lock() for index := range 14805 { server.voices = append(server.voices, fmt.Sprintf("Voice %d\ten-US\tnone", index)) } server.mu.Unlock() catalog, err := Discover(Options{Socket: server.path}) if err != nil || len(catalog.Voices["fixture"]) != 14805 || len(catalog.Errors) != 0 { t.Fatal(len(catalog.Voices["fixture"]), err, catalog.Errors) } for _, options := range []Options{ {Socket: server.path, Module: "absent"}, {Socket: server.path, Module: "fixture", Language: "de"}, {Socket: server.path, Module: "fixture", Voice: "missing"}, {Socket: server.path, Voice: "bad\r\nCANCEL ALL"}, } { if client, err := Prepare("speech-dispatcher", options); err == nil { client.Close() t.Fatal("accepted unavailable selection", options) } } candidate, err := Prepare("speech-dispatcher", Options{Socket: server.path, Module: "fixture", Language: "en-US", Voice: "Voice 12000"}) if err != nil { t.Fatal(err) } select { case event := <-server.events: t.Fatal("preparation spoke or canceled active speech", event) default: } server.mu.Lock() commands := strings.Join(server.settings, "|") server.mu.Unlock() if !strings.Contains(commands, "SET SELF LANGUAGE en-US|SET SELF SYNTHESIS_VOICE Voice 12000") { t.Fatal("voice must follow language") } candidate.Close() } func TestBoundedRepliesAndInjection(t *testing.T) { for _, reply := range []string{ "249-" + strings.Repeat("a", 5000) + "\n249 OK\n", strings.Repeat("249-"+strings.Repeat("a", 3000)+"\n", 3000) + "249 OK\n", "249-one\n250 OK\n", } { w := wire{reader: bufio.NewReaderSize(strings.NewReader(reply), 4096)} if _, err := w.reply(); err == nil { t.Fatal("accepted unbounded or malformed reply") } } for _, row := range []string{"Missing tabs", "name\ten\tvariant\textra", "name\ten\rCANCEL ALL", "bad\x00name\ten"} { if _, err := parse_voices([]string{row}); err == nil { t.Fatal("accepted malformed catalog row", row) } } } func TestEspeakLocaleDialectBaseAndExplicitLanguage(t *testing.T) { voices := []Voice{{Name: "US+Variant", Language: "en-US"}, {Name: "German", Language: "de"}, {Name: "US", Language: "en-US"}, {Name: "English", Language: "en"}} t.Setenv("LC_ALL", "en_US.UTF-8") got := Language_voices(voices, "") if !reflect.DeepEqual(got, []Voice{voices[2], voices[0]}) { t.Fatal(got) } t.Setenv("LC_ALL", "en_AU.UTF-8") if got := Language_voices(voices, ""); !reflect.DeepEqual(got, []Voice{voices[3]}) { t.Fatal(got) } if got := Language_voices(voices, "de"); !reflect.DeepEqual(got, []Voice{voices[1]}) { t.Fatal(got) } if got := Language_voices(voices, "unknown"); len(got) != 0 { t.Fatal(got) } t.Setenv("LC_ALL", "zz_ZZ") if got := Language_voices(voices, ""); !reflect.DeepEqual(got, voices) { t.Fatal(got) } if voices[0].Name != "US+Variant" { t.Fatal("catalog reordered in place") } } func TestCandidateRejectionLeavesOriginalClientUsable(t *testing.T) { server := new_fake_server(t, false) original, err := Open(Options{Socket: server.path}) if err != nil { t.Fatal(err) } defer original.Close() server.mu.Lock() server.reject = "SET SELF SYNTHESIS_VOICE" server.mu.Unlock() if candidate, err := Prepare("speech-dispatcher", Options{Socket: server.path, Module: "fixture", Voice: "Base Voice"}); err == nil { candidate.Close() t.Fatal("accepted rejected voice") } if err := original.Say("still working"); err != nil { t.Fatal(err) } wait_event(t, server.events, "say:still working") } func TestCatalogPreservesEspeakVoiceWhitespace(t *testing.T) { voices, err := parse_voices([]string{"Cherokee \tchr\tnone"}) if err != nil || len(voices) != 1 || voices[0].Name != "Cherokee " { t.Fatal(voices, err) } if err := Validate_selection("espeak-ng", "chr", "Cherokee "); err != nil { t.Fatal(err) } if err := Validate_selection("espeak-ng", "chr", " "); err == nil { t.Fatal("accepted whitespace-only name") } }