Files
goclaw/internal/audio/manager_fallback_sentinel_test.go
viettranx 04a9938f4f feat(tts): wire tenant timeout + fix Gemini text-only 400
- HTTP synthesize + test-connection now read tenant tts.timeout_ms
  (default 120s, was hardcoded 15s/10s). Gemini client default also
  bumped 30s→120s so both layers align when tenant config unset.
- Inline prefix "Speak naturally: " prepended to single-voice text;
  multi-speaker transcripts pass through unchanged.
- ErrTextOnlyResponse sentinel for 400 "text generation" bodies;
  single-voice retries once with stronger prefix. Narrowed needle
  list avoids false positives on unrelated 400s.
- SynthesizeWithFallbackAdapted now returns errors.Join so sentinel
  survives fallback chain; HTTP 422 mapping + locale-translated
  ForLLM in agent tool (EN/VI/ZH catalogs).
- Default Gemini model bumped to gemini-3.1-flash-tts-preview.
2026-04-23 08:31:53 +07:00

66 lines
2.2 KiB
Go

package audio_test
import (
"context"
"errors"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
)
// mockSentinelTTS returns a configurable error from Synthesize.
type mockSentinelTTS struct {
providerName string
err error
}
func (m *mockSentinelTTS) Name() string { return m.providerName }
func (m *mockSentinelTTS) Synthesize(_ context.Context, _ string, _ audio.TTSOptions) (*audio.SynthResult, error) {
return nil, m.err
}
// TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel verifies that
// ErrTextOnlyResponse survives through SynthesizeWithFallbackAdapted so that
// errors.Is(err, gemini.ErrTextOnlyResponse) returns true at the call site.
func TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel(t *testing.T) {
t.Run("primary_only_returns_sentinel", func(t *testing.T) {
// Single provider: primary returns ErrTextOnlyResponse. No fallback.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false; err = %v", err)
}
})
t.Run("primary_sentinel_plus_fallback_other_error", func(t *testing.T) {
// Primary returns ErrTextOnlyResponse; fallback returns a different error.
// Sentinel must survive errors.Join.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "openai",
err: errors.New("openai: connection refused"),
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false after errors.Join; err = %v", err)
}
})
}