Files

394 lines
13 KiB
Go
Raw Permalink Normal View History

package provider
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// fakePlayer records Play calls (the name drives the chain's null
// decision).
type fakePlayer struct {
name string
played [][]byte
}
func (f *fakePlayer) Play(_ context.Context, data []byte) error {
f.played = append(f.played, data)
return nil
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer responds with status + body and counts the requests
// (atomic; the server may see requests from several tiers).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
w.WriteHeader(status)
w.Write(body)
}))
t.Cleanup(ts.Close)
return ts.URL, &n
}
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin writes an executable shell script (same mechanism as in
// osbuiltin_test.go: real process, PATH manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
// cloudEnv returns the env map for a chain whose elevenlabs tier points at
// elevenURL and whose openai tier points at openaiURL (edge is dead so that
// no test touches the real Microsoft endpoint).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: elevenURL,
EnvOpenAIKey: "k",
EnvOpenAIEndpoint: openaiURL,
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}
for k, v := range extra {
kv[k] = v
}
return kv
}
// TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1
// (elevenlabs) answers 500 → the chain falls through to tier 2 (openai),
// whose bytes come back; the fallback is visible in the log, never silent.
func TestChainFallbackFirst500SecondHit(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("log = %q, want %q", log.String(), want)
}
}
}
// TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not
// available → the error names the failed tiers; the log shows each one.
func TestChainEverythingFails(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// Empty PATH: osbuiltin not available.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize with all tiers broken: no error")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("neither error (%v) nor log mention %q", err, want)
}
}
}
// TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any
// key and with a dead edge, the chain picks osbuiltin; with a faked espeak
// its bytes come back.
func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{EnvEdgeEndpoint: "ws://127.0.0.1:1/"}),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hello", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
}
if !strings.Contains(log.String(), "skipped (not available)") {
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
}
}
// TestChainPin: --provider/config pin the chain to exactly one tier; other
// tiers are not even requested.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
ch, err := NewChain(ChainConfig{
Pin: "openai",
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnknownPin is a clear error listing the names.
func TestChainUnknownPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain error = %v, want the available names", err)
}
}
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
// Player.
func TestChainSpeakBytesViaPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, eleven, nil)),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
}
}
// TestChainDirectWithNullPlayer: when the chain falls back to the direct
// tier (osbuiltin without espeak), a null player discards the announcement
// silently and SUCCEEDS; spd-say is not called.
func TestChainDirectWithNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: &fakePlayer{name: NullPlayerName},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (null player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say was called; the null player should have discarded")
}
if !strings.Contains(log.String(), "discarded") {
t.Errorf("log = %q, want discard line", log.String())
}
}
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
// direct announcement runs via SpeakDirect, blocking (-w) with language and
// text.
func TestChainDirectWithSoundPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
if len(player.played) != 0 {
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
// contract is preserved towards the caller.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
t.Setenv("PATH", dir)
ch, err := NewChain(ChainConfig{Pin: "osbuiltin", Getenv: chainEnv(nil)})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
// Chain itself is a DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain does not implement DirectSpeaker")
}
}
// TestChainAvailableAndNames: Available is true as soon as one tier is
// available; the default order is the binding one from the spec.
func TestChainAvailableAndNames(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false despite an available edge tier")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
t.Fatalf("Tiers = %d, want %d", len(ch.Tiers()), len(want))
}
for i, name := range want {
if ch.Tiers()[i].Name() != name {
t.Errorf("Tiers[%d] = %s, want %s", i, ch.Tiers()[i].Name(), name)
}
}
}
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
// VoicePinner; observable in the request path of the ElevenLabs fake.
func TestChainVoicePinForwarded(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
Voice: "ChainVoice",
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
}),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
}
// TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker
// endgame the chain walks exactly ONCE; the failed cloud tier is requested
// only once (no duplicate POSTs to paid APIs, no doubled latency), every
// tier logs its failure exactly once, and the direct announcement happens.
func TestChainDirectEndgameSingleWalk(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: key + fake (500). openai: endpoint but NO key → skipped.
// edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no
// espeak) → no bytes, direct announcement.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 {
t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "tier openai skipped"); got != 1 {
t.Errorf("openai skip appears %d times in the log, want exactly 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
}