Files
talk/internal/provider/chain_test.go
T

395 lines
13 KiB
Go
Raw Normal View History

package provider
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
// der Kette).
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 antwortet mit status + body und zählt die Requests
// (atomic, der Server kann mehrere Stufen-Requests sehen).
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 baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
// wie in osbuiltin_test.go — echter Prozess, 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 liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
// elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
// kein Test den echten Microsoft-Endpunkt anfasst).
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
}
// TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
// Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
// Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
// Log sichtbar, nie still.
func TestChainFallbackErster500ZweiterTreffer(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(), "Hallo Welt", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("Log = %q, will %q", log.String(), want)
}
}
}
// TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
// verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
func TestChainAllesFehlt(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// PATH leer: osbuiltin nicht verfügbar.
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(), "Hallo", "de")
if err == nil {
t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
}
}
}
// TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
// mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
// dessen Bytes zurück.
func TestChainOhneKeysEdgeOsbuiltin(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(), "Hallo", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
}
if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
}
}
// TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
// andere Stufen werden nicht einmal angefragt.
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(), "Hallo", "de"); err == nil {
t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
func TestChainUnbekannterPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
}
}
// TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
// Player abgespielt.
func TestChainSpeakBytesUeberPlayer(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(), "Hallo", "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 die Synthese-Bytes", player.played)
}
}
// TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
// (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
// SUCCESS — spd-say wird nicht gerufen.
func TestChainDirektMitNullPlayer(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) // kein espeak → keine Bytes, aber spd-say da
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(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (null-Player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
}
if !strings.Contains(log.String(), "verworfen") {
t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
}
}
// TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
// Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
func TestChainDirektMitTonPlayer(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(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
}
if len(player.played) != 0 {
t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
// ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
// DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
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(), "Hallo", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
}
// Chain selbst ist DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain implementiert DirectSpeaker nicht")
}
}
// TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
// Default-Reihenfolge ist die bindende aus der Spec.
func TestChainAvailableUndNamen(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false trotz verfügbarer edge-Stufe")
}
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)
}
}
}
// TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
// die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
func TestChainVoicePinDurchgereicht(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(), "Hallo", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
}
}
// TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
// Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
// wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
// keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
// genau einmal, und die Direktansage kommt zustande.
func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
// übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
// fake spd-say (kein espeak) → keine Bytes, Direktansage.
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, // ohne Key ohnehin übersprungen
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(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt-Endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
}
}