2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// fakePlayer records Play calls (the name drives the chain's null
|
|
|
|
|
// decision).
|
2026-10-18 16:34:43 +02:00
|
|
|
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 }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// countingTTSServer responds with status + body and counts the requests
|
|
|
|
|
// (atomic; the server may see requests from several tiers).
|
2026-10-18 16:34:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
|
2026-10-18 16:34:43 +02:00
|
|
|
func chainEnv(kv map[string]string) func(string) string {
|
|
|
|
|
return func(k string) string { return kv[k] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// fakeProcBin writes an executable shell script (same mechanism as in
|
|
|
|
|
// osbuiltin_test.go: real process, PATH manipulation).
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 16:34:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if string(data) != string(fakeMP3) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if elHits.Load() != 1 || oaHits.Load() != 1 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
|
2026-10-18 16:34:43 +02:00
|
|
|
if !strings.Contains(log.String(), want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("log = %q, want %q", log.String(), want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:34:43 +02:00
|
|
|
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
|
|
|
|
|
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Empty PATH: osbuiltin not available.
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err = ch.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Synthesize with all tiers broken: no error")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
|
|
|
|
|
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("neither error (%v) nor log mention %q", err, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := ch.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.HasPrefix(data, []byte("RIFF")) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(log.String(), "skipped (not available)") {
|
|
|
|
|
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestChainPin: --provider/config pin the chain to exactly one tier; other
|
|
|
|
|
// tiers are not even requested.
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
|
|
|
|
|
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if elHits.Load() != 0 || oaHits.Load() != 1 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
|
|
|
|
|
// Player.
|
|
|
|
|
func TestChainSpeakBytesViaPlayer(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Fatalf("Speak: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:34:43 +02:00
|
|
|
dir := t.TempDir()
|
|
|
|
|
seen := filepath.Join(dir, "seen.txt")
|
|
|
|
|
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
|
2026-10-18 16:34:43 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
|
|
|
|
|
t.Fatalf("Speak (null player): %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(seen); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("spd-say was called; the null player should have discarded")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(log.String(), "discarded") {
|
|
|
|
|
t.Errorf("log = %q, want discard line", log.String())
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
|
|
|
|
|
// direct announcement runs via SpeakDirect, blocking (-w) with language and
|
|
|
|
|
// text.
|
|
|
|
|
func TestChainDirectWithSoundPlayer(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
|
|
|
|
|
t.Fatalf("Speak (direct): %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
args, err := os.ReadFile(seen)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("fake spd-say not called: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if len(player.played) != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
|
|
|
|
|
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
|
|
|
|
|
// contract is preserved towards the caller.
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err = ch.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if !errors.Is(err, ErrNoByteOutput) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Chain itself is a DirectSpeaker.
|
2026-10-18 16:34:43 +02:00
|
|
|
if _, ok := any(ch).(DirectSpeaker); !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Chain does not implement DirectSpeaker")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:34:43 +02:00
|
|
|
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
|
2026-10-24 11:15:35 +02:00
|
|
|
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
|
2026-10-18 16:34:43 +02:00
|
|
|
})})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewChain: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !ch.Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available = false despite an available edge tier")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
|
|
|
|
|
// VoicePinner; observable in the request path of the ElevenLabs fake.
|
|
|
|
|
func TestChainVoicePinForwarded(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("path = %q, want %q", rec.path, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 16:41:23 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 16:41:23 +02:00
|
|
|
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:41:23 +02:00
|
|
|
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,
|
2026-10-24 11:15:35 +02:00
|
|
|
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
|
2026-10-18 16:41:23 +02:00
|
|
|
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
|
|
|
|
|
}),
|
|
|
|
|
Player: &fakePlayer{name: "oto"},
|
|
|
|
|
Log: &log,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewChain: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
|
|
|
|
|
t.Fatalf("Speak (direct endgame): %v", err)
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
if got := elHits.Load(); got != 1 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
for _, tier := range []string{"elevenlabs", "edge"} {
|
2026-10-24 11:15:35 +02:00
|
|
|
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())
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
args, err := os.ReadFile(seen)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("fake spd-say not called: %v", err)
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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")
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
}
|