ffef44f7bb
Tests cover all Send() code paths: - Nil TTS synthesizer returns tts-not-wired error - Nil sessions registry returns sessions-not-wired error - No live session returns delivery.ErrVoiceNoSession (dispatcher reroutes) - With session: pushes audio_nudge frame with correct kind, rule_name, text, PCM16kMono format, non-empty audio bytes - Empty Body falls back to Summary text - TTS synthesize error propagates with 'synthesize:' prefix - Invalid audio format rejects with 'refuse to ship' error Uses net.Pipe() for real voice.Sessions integration, tts.Stub for deterministic synthesis, and fake synthesizers for error paths.
219 lines
5.3 KiB
Go
219 lines
5.3 KiB
Go
package voicesink
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/tts"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
type frameResult struct {
|
|
buf []byte
|
|
err error
|
|
}
|
|
|
|
func readFrame(r net.Conn, ch chan<- frameResult) {
|
|
var hdr [4]byte
|
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
|
ch <- frameResult{err: err}
|
|
return
|
|
}
|
|
n := binary.BigEndian.Uint32(hdr[:])
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
ch <- frameResult{err: err}
|
|
return
|
|
}
|
|
ch <- frameResult{buf: buf}
|
|
}
|
|
|
|
func unmarshalFrame(t *testing.T, buf []byte) (kind string, params json.RawMessage) {
|
|
t.Helper()
|
|
var frame struct {
|
|
Kind string `json:"kind"`
|
|
Params json.RawMessage `json:"p"`
|
|
}
|
|
if err := json.Unmarshal(buf, &frame); err != nil {
|
|
t.Fatalf("unmarshal frame: %v", err)
|
|
}
|
|
return frame.Kind, frame.Params
|
|
}
|
|
|
|
func unmarshalPush(t *testing.T, raw json.RawMessage) voice.AudioNudgePush {
|
|
t.Helper()
|
|
var push voice.AudioNudgePush
|
|
if err := json.Unmarshal(raw, &push); err != nil {
|
|
t.Fatalf("unmarshal push params: %v", err)
|
|
}
|
|
return push
|
|
}
|
|
|
|
// ---- nil guards ----
|
|
|
|
func TestSinkSend_NilTTSError(t *testing.T) {
|
|
s := New(nil, voice.NewSessions())
|
|
err := s.Send(context.Background(), delivery.Sendable{Body: "test"})
|
|
if err == nil || !strings.Contains(err.Error(), "tts synthesizer not wired") {
|
|
t.Fatalf("expected tts-not-wired error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSinkSend_NilSessionsError(t *testing.T) {
|
|
s := New(tts.NewStub(), nil)
|
|
err := s.Send(context.Background(), delivery.Sendable{Body: "test"})
|
|
if err == nil || !strings.Contains(err.Error(), "sessions registry not wired") {
|
|
t.Fatalf("expected sessions-not-wired error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- no session ----
|
|
|
|
func TestSinkSend_NoSessionReturnsErrVoiceNoSession(t *testing.T) {
|
|
sess := voice.NewSessions()
|
|
s := New(tts.NewStub(), sess)
|
|
err := s.Send(context.Background(), delivery.Sendable{Body: "hello"})
|
|
if !errors.Is(err, delivery.ErrVoiceNoSession) {
|
|
t.Fatalf("expected ErrVoiceNoSession, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- successful push ----
|
|
|
|
func TestSinkSend_WithSessionPushesAudio(t *testing.T) {
|
|
r, w := net.Pipe()
|
|
defer r.Close()
|
|
defer w.Close()
|
|
|
|
ch := make(chan frameResult, 1)
|
|
go readFrame(r, ch)
|
|
|
|
ctx := context.Background()
|
|
sess := voice.NewSessions()
|
|
sess.Add(w, voice.SurfacePCClient)
|
|
s := New(tts.NewStub(), sess)
|
|
|
|
err := s.Send(ctx, delivery.Sendable{
|
|
RuleName: "test_rule",
|
|
Body: "Hello world",
|
|
Kind: delivery.KindNudge,
|
|
Ts: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
res := <-ch
|
|
if res.err != nil {
|
|
t.Fatalf("read frame: %v", res.err)
|
|
}
|
|
|
|
kind, params := unmarshalFrame(t, res.buf)
|
|
if kind != "audio_nudge" {
|
|
t.Errorf("push kind = %q, want %q", kind, "audio_nudge")
|
|
}
|
|
|
|
push := unmarshalPush(t, params)
|
|
if push.RuleName != "test_rule" {
|
|
t.Errorf("rule_name = %q, want %q", push.RuleName, "test_rule")
|
|
}
|
|
if push.Text != "Hello world" {
|
|
t.Errorf("text = %q, want %q", push.Text, "Hello world")
|
|
}
|
|
if push.Audio.Format != audio.PCM16kMono {
|
|
t.Errorf("audio format = %v, want %v", push.Audio.Format, audio.PCM16kMono)
|
|
}
|
|
if len(push.Audio.Bytes) == 0 {
|
|
t.Error("audio bytes are empty")
|
|
}
|
|
}
|
|
|
|
// ---- empty body falls back to summary ----
|
|
|
|
func TestSinkSend_EmptyBodyFallsBackToSummary(t *testing.T) {
|
|
r, w := net.Pipe()
|
|
defer r.Close()
|
|
defer w.Close()
|
|
|
|
ch := make(chan frameResult, 1)
|
|
go readFrame(r, ch)
|
|
|
|
sess := voice.NewSessions()
|
|
sess.Add(w, voice.SurfacePCClient)
|
|
s := New(tts.NewStub(), sess)
|
|
|
|
err := s.Send(context.Background(), delivery.Sendable{
|
|
Body: "",
|
|
Summary: "Fallback summary",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
res := <-ch
|
|
if res.err != nil {
|
|
t.Fatalf("read frame: %v", res.err)
|
|
}
|
|
|
|
_, params := unmarshalFrame(t, res.buf)
|
|
push := unmarshalPush(t, params)
|
|
if push.Text != "Fallback summary" {
|
|
t.Errorf("text = %q, want %q", push.Text, "Fallback summary")
|
|
}
|
|
}
|
|
|
|
// ---- fake synths ----
|
|
|
|
type errSynth struct{}
|
|
|
|
func (e *errSynth) Synthesize(_ context.Context, text string) (audio.Audio, error) {
|
|
return audio.Audio{}, fmt.Errorf("synthesis failed")
|
|
}
|
|
|
|
func TestSinkSend_SynthesizeError(t *testing.T) {
|
|
sess := voice.NewSessions()
|
|
r, w := net.Pipe()
|
|
defer r.Close()
|
|
defer w.Close()
|
|
sess.Add(w, voice.SurfacePCClient)
|
|
|
|
s := New(&errSynth{}, sess)
|
|
err := s.Send(context.Background(), delivery.Sendable{Body: "test"})
|
|
if err == nil || !strings.Contains(err.Error(), "synthesize") {
|
|
t.Fatalf("expected synthesize error, got %v", err)
|
|
}
|
|
}
|
|
|
|
type badFormatSynth struct{}
|
|
|
|
func (b *badFormatSynth) Synthesize(_ context.Context, text string) (audio.Audio, error) {
|
|
return audio.Audio{Format: audio.Format{SampleRate: 999, Channels: 0, SampleBits: 0, Encoding: "bad"}, Bytes: []byte{0, 1, 2, 3}}, nil
|
|
}
|
|
|
|
func TestSinkSend_RejectsBadAudioFormat(t *testing.T) {
|
|
r, w := net.Pipe()
|
|
defer r.Close()
|
|
defer w.Close()
|
|
sess := voice.NewSessions()
|
|
sess.Add(w, voice.SurfacePCClient)
|
|
|
|
s := New(&badFormatSynth{}, sess)
|
|
err := s.Send(context.Background(), delivery.Sendable{Body: "test"})
|
|
if err == nil {
|
|
t.Fatal("expected error for bad audio format, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "PCM16kMono") {
|
|
t.Errorf("error = %q, want error containing 'PCM16kMono'", err.Error())
|
|
}
|
|
}
|