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()) } }