voicesink: add comprehensive test suite (7 tests, race-clean)
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.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user