diff --git a/internal/delivery/voicesink/voicesink_test.go b/internal/delivery/voicesink/voicesink_test.go new file mode 100644 index 0000000..568a560 --- /dev/null +++ b/internal/delivery/voicesink/voicesink_test.go @@ -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()) + } +}