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:
kami
2026-07-05 02:36:57 +04:00
parent 185f4f578e
commit ffef44f7bb
@@ -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())
}
}