// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func remote_render_frame(priority int, parts ...tentaclewire.RemoteSpeechPart) tentaclewire.RemoteSpeech { return tentaclewire.RemoteSpeech{Kind: "speak", Priority: priority, Parts: parts} } func (server *isolatedVoiceServer) next_utterance(t *testing.T) isolatedVoiceUtterance { t.Helper() select { case utterance := <-server.queued: return utterance case <-time.After(3 * time.Second): t.Fatal("synthesis did not receive a fragment") return isolatedVoiceUtterance{} } } func (server *isolatedVoiceServer) end_utterance(t *testing.T, utterance isolatedVoiceUtterance) { t.Helper() server.mutex.Lock() connection := server.connections[utterance.id] connection.pending = "" server.mutex.Unlock() connection.send(fmt.Sprintf("702-1\r\n702-%d\r\n702 END\r\n", utterance.id)) } func remote_render_result(t *testing.T, finished <-chan error) error { t.Helper() select { case err := <-finished: return err case <-time.After(3 * time.Second): t.Fatal("rendering did not finish") return nil } } func TestRemoteSpeechRenderingPreservesAssemblyAndLiteralMetadata(t *testing.T) { speaker, server := isolated_voice_fixture(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } frames := []tentaclewire.RemoteSpeech{ remote_render_frame(2, tentaclewire.RemoteSpeechPart{Text: " hello"}), remote_render_frame(2, tentaclewire.RemoteSpeechPart{Text: " world\t"}, tentaclewire.RemoteSpeechPart{Text: "aa", Characters: true}, tentaclewire.RemoteSpeechPart{Text: "é\n.\nQUIT\r\n..literal\rend ", Language: "fr"}, tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{Text: " again "}), } utterance, err := prepare_remote_utterance(frames) if err != nil { t.Fatal(err) } if utterance.priority != 2 || len(utterance.fragments) != 4 { t.Fatal("assembly lost priority or metadata boundaries") } finished := make(chan error, 1) go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }() for index, expected := range []struct{ text, language, spelling string }{ {" hello world\t", "en-GB", "off"}, {"aa", "en-GB", "on"}, {"é\n.\nQUIT\n..literal\nend ", "fr", "off"}, {" again ", "en-GB", "off"}, } { got := server.next_utterance(t) if got.text != expected.text || got.language != expected.language || got.spelling != expected.spelling || got.ssml != "off" || got.module != "default" || got.voiceType != "FEMALE2" || got.synthesis != "" { t.Fatalf("fragment %d = %#v; want %#v in plain-text mode", index, got, expected) } select { case <-finished: t.Fatal("queue acknowledgement finished assembly") default: } server.end_utterance(t, got) } if err := remote_render_result(t, finished); err != nil { t.Fatal(err) } // Successful rendering restores the effective server default and leaves // the handle reusable. Transport splits are not synthesis boundaries. large := strings.Repeat("界 ", 1200) frames = nil for _, part := range []string{large[:3000], large[3000:]} { frames = append(frames, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: part})) } utterance, err = prepare_remote_utterance(frames) if err != nil { t.Fatal(err) } go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }() got := server.next_utterance(t) if got.text != large || got.language != "en-GB" || got.spelling != "off" { t.Fatal("next assembly inherited state or lost split text") } server.end_utterance(t, got) if err := remote_render_result(t, finished); err != nil { t.Fatal(err) } } func TestRemoteSpeechRenderingRestoresNamedVoice(t *testing.T) { speaker, server := isolated_voice_fixture(t) settings := speaker.settings settings.DefaultVoice.Family = &config.VoiceFamily{Name: "configured voice"} if err := speaker.UpdateConfiguration(t.Context(), settings); err != nil { t.Fatal(err) } voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "default"}, tentaclewire.RemoteSpeechPart{Text: "foreign", Language: "fr"}, tentaclewire.RemoteSpeechPart{Text: "restored"})}) if err != nil { t.Fatal(err) } finished := make(chan error, 1) go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }() for _, named := range []string{"configured voice", "", "configured voice"} { got := server.next_utterance(t) if got.synthesis != named || got.module != "default" || named == "" && got.voiceType != "MALE1" { t.Fatalf("language switch did not scope the named voice: %#v", got) } server.end_utterance(t, got) } if err := remote_render_result(t, finished); err != nil { t.Fatal(err) } } func TestRemoteSpeechRenderingRejectsUnknownBackendDefaults(t *testing.T) { for _, test := range []struct{ command, reply string }{ {"GET LANGUAGE", "400 Unsupported\r\n"}, {"GET LANGUAGE", "251-en GB\r\n251 OK\r\n"}, {"GET LANGUAGE", "251-en\r\n251-fr\r\n251 OK\r\n"}, {"GET OUTPUT_MODULE", "251-unsafe module\r\n251 OK\r\n"}, {"GET VOICE_TYPE", "251-unknown\r\n251 OK\r\n"}, {"GET VOICE_TYPE", "251-NO_VOICE\r\n251 OK\r\n"}, } { t.Run(test.command+test.reply, func(t *testing.T) { speaker, server := isolated_voice_fixture(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } server.mutex.Lock() server.queryReplies = map[string]string{test.command: test.reply} server.mutex.Unlock() utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "never queued"})}) if err != nil { t.Fatal(err) } if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil { t.Fatal("unresolved backend defaults admitted") } if voice.ctx.Err() == nil { t.Fatal("unresolved backend handle survived") } select { case <-server.queued: t.Fatal("unresolved defaults synthesized output") default: } }) } } func TestRemoteSpeechRenderingCancellationDuringMetadata(t *testing.T) { speaker, server := isolated_voice_fixture(t) if err := speaker.SpeakVoice(t.Context(), "local status", "system"); err != nil { t.Fatal(err) } server.next_utterance(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } held, resume := make(chan struct{}), make(chan struct{}) defer close(resume) server.mutex.Lock() server.holdCommand, server.held, server.resume = "GET LANGUAGE", held, resume server.mutex.Unlock() utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "never queued"})}) if err != nil { t.Fatal(err) } finished := make(chan error, 1) go func() { finished <- voice.SpeakUtteranceAndWait(t.Context(), utterance) }() select { case <-held: case <-time.After(3 * time.Second): t.Fatal("metadata query did not start") } if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil { t.Fatal("overlapping typed assembly admitted") } if err := voice.Cancel(t.Context()); err != nil { t.Fatal(err) } if err := remote_render_result(t, finished); !errors.Is(err, context.Canceled) { t.Fatal("metadata cancellation was lost", err) } if id, _ := server.pending("local status"); id == 0 { t.Fatal("metadata cancellation discarded local status") } select { case <-server.queued: t.Fatal("metadata cancellation synthesized output") default: } } func TestRemoteSpeechRenderingCancellationKeepsLocalOutputAndDropsSuffix(t *testing.T) { for _, operation := range []string{"remote", "caller", "configuration"} { t.Run(operation, func(t *testing.T) { speaker, server := isolated_voice_fixture(t) if err := speaker.SpeakVoice(t.Context(), "local status", "system"); err != nil { t.Fatal(err) } server.next_utterance(t) if err := speaker.SpeakEcho(t.Context(), "local echo"); err != nil { t.Fatal(err) } server.next_utterance(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "prefix", Characters: true, Language: "fr"}, tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{Text: "suffix"})}) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(t.Context()) defer cancel() finished := make(chan error, 1) go func() { finished <- voice.SpeakUtteranceAndWait(ctx, utterance) }() server.next_utterance(t) switch operation { case "remote": err = voice.Cancel(t.Context()) case "caller": cancel() case "configuration": // A committed replacement is modeled without cancelling local // speech: retirement must target only the old isolated handle. speaker.retire_remote_voice(speaker.settings) } if err != nil { t.Fatal(err) } if err := remote_render_result(t, finished); !errors.Is(err, context.Canceled) { t.Fatal("rendering survived cancellation", err) } for _, text := range []string{"local status", "local echo"} { if id, _ := server.pending(text); id == 0 { t.Fatal("Remote discarded local output", text) } } select { case <-server.queued: t.Fatal("cancelled assembly exposed its suffix") default: } if voice.ctx.Err() == nil { t.Fatal("partially rendered handle was reused") } if _, err := speaker.open_remote_voice(t.Context()); err != nil { t.Fatal("retired renderer retained ownership", err) } }) } } func TestRemoteSpeechRenderingRejectsWholeInvalidAssembly(t *testing.T) { valid := remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "prefix"}) for _, frames := range [][]tentaclewire.RemoteSpeech{ nil, {valid, {Kind: "cancel"}}, {valid, remote_render_frame(1, tentaclewire.RemoteSpeechPart{Text: "changed priority"})}, {valid, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "bad", Language: "fr\nQUIT"})}, {valid, remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: strings.Repeat("x", 6200)})}, append(make([]tentaclewire.RemoteSpeech, 64), valid), } { if _, err := prepare_remote_utterance(frames); err == nil { t.Fatal("invalid assembly admitted") } } // Each frame fits IPC, but the retained assembly exceeds the byte budget. frames := make([]tentaclewire.RemoteSpeech, 64) for index := range frames { frames[index] = remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: strings.Repeat("x", 5000)}) } if _, err := prepare_remote_utterance(frames); err == nil { t.Fatal("oversized assembly admitted") } for _, invalid := range []string{"bad\x00text", "bad\x1btext", "bad\x7ftext"} { speaker, server := isolated_voice_fixture(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{valid, remote_render_frame(0, tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{Text: invalid})}) if err != nil { t.Fatal("wire-valid fixture rejected too early", err) } if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil { t.Fatal("unsafe text rendered") } select { case <-server.queued: t.Fatal("invalid later fragment exposed an earlier prefix") default: } } } func TestRemoteSpeechRenderingBackendRejectionAndEmptyBoundaries(t *testing.T) { speaker, server := isolated_voice_fixture(t) voice, err := speaker.open_remote_voice(t.Context()) if err != nil { t.Fatal(err) } utterance, err := prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{End: true}, tentaclewire.RemoteSpeechPart{End: true})}) if err != nil { t.Fatal(err) } if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err != nil { t.Fatal(err) } utterance, err = prepare_remote_utterance([]tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "never queued", Language: "rejected"})}) if err != nil { t.Fatal(err) } if err := voice.SpeakUtteranceAndWait(t.Context(), utterance); err == nil { t.Fatal("rejected metadata ignored") } if voice.ctx.Err() == nil { t.Fatal("failed metadata handle survived") } select { case <-server.queued: t.Fatal("empty boundary or rejected metadata synthesized text") default: } }