Files
talk/internal/provider/chain_test.go
T
m3ta-chiron 3b44605234 fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette
Speak rief bei ErrNoByteOutput SpeakDirect auf, das einen eigenen
vollständigen walk machte: fehlgeschlagene Cloud-Stufen wurden zweimal
angefragt (doppelte POSTs auf bezahlten APIs), Fehlschlag-Zeilen
verdoppelten sich im Log, die Worst-Case-Latenz verdoppelte sich, und
Bytes aus einer transienten Erholung im zweiten Durchlauf hätten einen
Fehler statt Wiedergabe produziert.

Speak entscheidet jetzt aus EINEM walk-Ergebnis (Bytes → Player,
nur-Direkt-Stufe → SpeakDirect direkt daran, sonst gebündelter
Stufen-Fehler aus dem geteilten Helper walkResult.noTierErr).
Synthesize/SpeakDirect behalten ihre öffentlichen Verträge; null-Player
verwirft tonlos mit Exit 0 wie zuvor.

Test: TestChainDirektEndgameEinDurchlauf pinnt Cloud-Stufe genau 1×
angefragt, jede Fehlschlag-Zeile genau 1×, Direktansage kommt zustande.
2026-10-18 16:41:23 +02:00

396 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}