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