Files
viettranx ee57ec0fdc feat(tts): expand per-provider params + validation + golden fixtures
Phase 1 of the TTS params/layout/agent-override plan.

Capability schema:
- `ParamSchema.Group` field (`"basic"` default, `"advanced"` when set).
- Tag existing advanced params across openai/elevenlabs/minimax/gemini.

Gemini:
- Expose `temperature` (basic, 0.0–2.0, default 1.0, subtle-effect note),
  `seed`, `presencePenalty`, `frequencyPenalty` (advanced, experimental).
- Merge into `generationConfig` via explicit-presence resolvers so
  nil-params bodies stay byte-equivalent.

ElevenLabs:
- `output_format` enum (27 variants, default `mp3_44100_128`, advanced).
- `FormatMeta` lookup drives SynthResult MIME + extension.
- URL built via `net/url.Values.Encode()`; regex pre-validation for
  `output_format` (`^[a-z0-9_]+$`) and `language_code`
  (`^[a-z]{2,3}(-[A-Z]{2})?$`) blocks query-string injection.
- Telegram opus contract preserved: `opts.Format=="opus"` forces
  `audio/ogg; codecs=opus` regardless of user-set `output_format`.

MiniMax:
- `language_boost` (basic enum), `subtitle_enable` (basic bool),
  `pronunciation_dict` (advanced text, 8KB cap, wrapped as
  `{"tone":[...]}`). Parse failure logs length only + omits.

Validation:
- `audio.ValidateParams` enforces Min/Max/Enum + rejects unknown keys.
- Wired into `/v1/tts/synthesize` and `/v1/tts/config` write paths.
- `loadParamsBlob` capped at 16KB.
- i18n keys `MsgTtsParamOutOfRange`, `MsgTtsParamInvalidJSON`,
  `MsgTtsParamUnknownKey` added to en/vi/zh catalogs.

Tests:
- Golden `testdata/default_body.golden.json` per provider (gemini,
  elevenlabs, minimax, openai) checked in; invariant tests diff
  against file instead of self-referential capture.
- Round-trip tests for each new param + Telegram opus contract +
  URL-injection attempts.
2026-04-20 00:20:50 +07:00

141 lines
4.6 KiB
Go

package gemini_test
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
)
// TestSynthesize_Params_Temperature verifies temperature lands in generationConfig.
func TestSynthesize_Params_Temperature(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"temperature": 1.5},
})
gc := assertGeminiGenerationConfig(t, body)
if v, _ := gc["temperature"].(float64); v != 1.5 {
t.Errorf("temperature: got %v, want 1.5", gc["temperature"])
}
}
// TestSynthesize_Params_Seed verifies seed lands in generationConfig.
func TestSynthesize_Params_Seed(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"seed": 12345},
})
gc := assertGeminiGenerationConfig(t, body)
// JSON round-trip: int comes back as float64.
if v, _ := gc["seed"].(float64); int(v) != 12345 {
t.Errorf("seed: got %v, want 12345", gc["seed"])
}
}
// TestSynthesize_Params_PresencePenalty verifies presencePenalty in generationConfig.
func TestSynthesize_Params_PresencePenalty(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"presencePenalty": 0.3},
})
gc := assertGeminiGenerationConfig(t, body)
if v, _ := gc["presencePenalty"].(float64); v != 0.3 {
t.Errorf("presencePenalty: got %v, want 0.3", gc["presencePenalty"])
}
}
// TestSynthesize_Params_FrequencyPenalty verifies frequencyPenalty in generationConfig.
func TestSynthesize_Params_FrequencyPenalty(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"frequencyPenalty": -0.1},
})
gc := assertGeminiGenerationConfig(t, body)
if v, _ := gc["frequencyPenalty"].(float64); v != -0.1 {
t.Errorf("frequencyPenalty: got %v, want -0.1", gc["frequencyPenalty"])
}
}
// TestSynthesize_Params_AllFour verifies all four params land together.
func TestSynthesize_Params_AllFour(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{
Params: map[string]any{
"temperature": 1.5,
"seed": 12345,
"presencePenalty": 0.3,
"frequencyPenalty": -0.1,
},
})
gc := assertGeminiGenerationConfig(t, body)
if v, _ := gc["temperature"].(float64); v != 1.5 {
t.Errorf("temperature: got %v, want 1.5", gc["temperature"])
}
if v, _ := gc["seed"].(float64); int(v) != 12345 {
t.Errorf("seed: got %v, want 12345", gc["seed"])
}
if v, _ := gc["presencePenalty"].(float64); v != 0.3 {
t.Errorf("presencePenalty: got %v, want 0.3", gc["presencePenalty"])
}
if v, _ := gc["frequencyPenalty"].(float64); v != -0.1 {
t.Errorf("frequencyPenalty: got %v, want -0.1", gc["frequencyPenalty"])
}
}
// TestSynthesize_Params_NilParams_NoExtraKeys verifies nil params produces no
// extra keys in generationConfig beyond the mandatory ones.
func TestSynthesize_Params_NilParams_NoExtraKeys(t *testing.T) {
cfg := gemini.Config{APIKey: "k"}
body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{})
gc := assertGeminiGenerationConfig(t, body)
for _, extra := range []string{"temperature", "seed", "presencePenalty", "frequencyPenalty"} {
if _, ok := gc[extra]; ok {
t.Errorf("nil params: generationConfig must not contain %q", extra)
}
}
}
// TestCapabilities_Gemini_HasFourParams confirms Capabilities returns exactly
// the four documented params with correct Group tags.
func TestCapabilities_Gemini_HasFourParams(t *testing.T) {
p := gemini.NewProvider(gemini.Config{APIKey: "k"})
caps := p.Capabilities()
type want struct {
group string
}
expected := map[string]want{
"temperature": {group: ""},
"seed": {group: "advanced"},
"presencePenalty": {group: "advanced"},
"frequencyPenalty": {group: "advanced"},
}
found := map[string]bool{}
for _, p := range caps.Params {
if w, ok := expected[p.Key]; ok {
found[p.Key] = true
if p.Group != w.group {
t.Errorf("param %q: Group got %q, want %q", p.Key, p.Group, w.group)
}
}
}
for key := range expected {
if !found[key] {
t.Errorf("param %q not found in Capabilities", key)
}
}
}
// assertGeminiGenerationConfig extracts generationConfig from the body and fails
// if it is missing or not a map.
func assertGeminiGenerationConfig(t *testing.T, body map[string]any) map[string]any {
t.Helper()
gc, ok := body["generationConfig"].(map[string]any)
if !ok {
t.Fatalf("generationConfig missing or not a map: %#v", body["generationConfig"])
}
return gc
}