package app import ( "context" "errors" "testing" "gitea.nik4nao.com/nik/home-services/tts-gateway/internal/core/domain" ) type mockNormalizer struct { normalizeFunc func(ctx context.Context, text string) ([]int64, error) } func (m *mockNormalizer) Normalize(ctx context.Context, text string) ([]int64, error) { return m.normalizeFunc(ctx, text) } type mockEngine struct { synthesizeFunc func(ctx context.Context, speakerID int, symbolIDs []int64, params domain.SynthesisParams) ([]float32, int, error) } func (m *mockEngine) Synthesize(ctx context.Context, speakerID int, symbolIDs []int64, params domain.SynthesisParams) ([]float32, int, error) { return m.synthesizeFunc(ctx, speakerID, symbolIDs, params) } type mockEncoder struct { encodeFunc func(ctx context.Context, pcm []float32, sampleRate int) ([]byte, string, error) } func (m *mockEncoder) Encode(ctx context.Context, pcm []float32, sampleRate int) ([]byte, string, error) { return m.encodeFunc(ctx, pcm, sampleRate) } func TestTTSAppSynthesize(t *testing.T) { t.Run("unknown speaker returns ErrSpeakerNotFound without calling downstream ports", func(t *testing.T) { a := NewTTSApp( &mockNormalizer{normalizeFunc: func(context.Context, string) ([]int64, error) { t.Fatal("Normalize should not be called for an unknown speaker") return nil, nil }}, &mockEngine{}, &mockEncoder{}, ) _, err := a.Synthesize(context.Background(), "Not A Real Speaker", "hello", domain.SynthesisParams{}) if !errors.Is(err, ErrSpeakerNotFound) { t.Fatalf("Synthesize() error = %v, want %v", err, ErrSpeakerNotFound) } }) t.Run("happy path wires normalize -> engine -> encode", func(t *testing.T) { wantParams := domain.SynthesisParams{NoiseScale: 0.1, NoiseScaleW: 0.2, LengthScale: 0.3} wantSymbolIDs := []int64{1, 2, 3} wantPCM := []float32{0.5, -0.5} wantSampleRate := 22050 wantAudio := []byte{1, 2, 3, 4} wantMimeType := "audio/aac" a := NewTTSApp( &mockNormalizer{normalizeFunc: func(_ context.Context, text string) ([]int64, error) { if text != "hello" { t.Fatalf("Normalize() text = %q, want %q", text, "hello") } return wantSymbolIDs, nil }}, &mockEngine{synthesizeFunc: func(_ context.Context, speakerID int, symbolIDs []int64, params domain.SynthesisParams) ([]float32, int, error) { if speakerID != 29 { t.Fatalf("Synthesize() speakerID = %d, want 29 (Rice Shower)", speakerID) } if len(symbolIDs) != len(wantSymbolIDs) { t.Fatalf("Synthesize() symbolIDs = %v, want %v", symbolIDs, wantSymbolIDs) } if params != wantParams { t.Fatalf("Synthesize() params = %v, want %v", params, wantParams) } return wantPCM, wantSampleRate, nil }}, &mockEncoder{encodeFunc: func(_ context.Context, pcm []float32, sampleRate int) ([]byte, string, error) { if sampleRate != wantSampleRate { t.Fatalf("Encode() sampleRate = %d, want %d", sampleRate, wantSampleRate) } return wantAudio, wantMimeType, nil }}, ) got, err := a.Synthesize(context.Background(), "Rice Shower", "hello", wantParams) if err != nil { t.Fatalf("Synthesize() error = %v", err) } if string(got.Data) != string(wantAudio) || got.MimeType != wantMimeType { t.Fatalf("Synthesize() = %+v, want Data=%v MimeType=%q", got, wantAudio, wantMimeType) } }) t.Run("propagates normalizer error", func(t *testing.T) { wantErr := errors.New("normalize failed") a := NewTTSApp( &mockNormalizer{normalizeFunc: func(context.Context, string) ([]int64, error) { return nil, wantErr }}, &mockEngine{}, &mockEncoder{}, ) _, err := a.Synthesize(context.Background(), "Rice Shower", "hello", domain.SynthesisParams{}) if !errors.Is(err, wantErr) { t.Fatalf("Synthesize() error = %v, want %v", err, wantErr) } }) } func TestTTSAppListSpeakers(t *testing.T) { a := NewTTSApp(&mockNormalizer{}, &mockEngine{}, &mockEncoder{}) t.Run("empty search returns full roster", func(t *testing.T) { got := a.ListSpeakers(context.Background(), "") if len(got) != len(domain.Speakers) { t.Fatalf("ListSpeakers(\"\") returned %d names, want %d", len(got), len(domain.Speakers)) } }) t.Run("case-insensitive substring filter", func(t *testing.T) { got := a.ListSpeakers(context.Background(), "rice") if len(got) != 1 || got[0] != "Rice Shower" { t.Fatalf("ListSpeakers(\"rice\") = %v, want [\"Rice Shower\"]", got) } }) t.Run("no match returns empty, not nil roster", func(t *testing.T) { got := a.ListSpeakers(context.Background(), "definitely not a speaker") if len(got) != 0 { t.Fatalf("ListSpeakers() = %v, want empty", got) } }) }