Preserve literal whitespace and resolved spelling/language state across complete Remote speech assemblies. Isolate cancellation from local status and echo, retire handles on configuration changes or rendering failure, and cover metadata preparation cancellation with private SSIP fixtures. Verified focused application and backend race tests, ten repetitions of isolated-client/rendering cases, shared speech regressions, affected-package vet, reader build and Tentacles checks. Incoming mailbox integration, local priority and live audible acceptance remain pending.
372 lines
13 KiB
Go
372 lines
13 KiB
Go
// 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 <speak> ", 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 <speak> ", "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:
|
|
}
|
|
}
|