package provider import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) // fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das // body ausführt. Damit werden die externen Prozesse des Providers an der // echten Prozessgrenze gefaked (PATH-Manipulation, kein Mock-Framework). func fakeBin(t *testing.T, dir, name, body string) { t.Helper() script := "#!/bin/sh\n" + body + "\n" path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatal(err) } } // emptyPATH setzt PATH auf ein Verzeichnis ohne einzige Binärdatei — // kein espeak, kein spd-say. func emptyPATH(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("PATH", dir) return dir } // prependPATH stellt dir vor den bestehenden PATH: Binaries darin // überschatten System-Binaries gleichen Namens (fake espeak vor einem // echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben. func prependPATH(t *testing.T, dir string) { t.Helper() t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) } // TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht. func TestOSBuiltinName(t *testing.T) { if got := (OSBuiltin{}).Name(); got != "osbuiltin" { t.Errorf("Name() = %q, want %q", got, "osbuiltin") } } func TestNewRegistry(t *testing.T) { for _, name := range []string{"", "osbuiltin"} { p, err := New(name, ChainConfig{}) if err != nil { t.Fatalf("New(%q): %v", name, err) } if p.Name() != "osbuiltin" { t.Errorf("New(%q).Name() = %q", name, p.Name()) } } // Task 03: die Cloud-Provider stehen in der Registry. for _, name := range []string{"elevenlabs", "openai", "edge"} { p, err := New(name, ChainConfig{}) if err != nil { t.Fatalf("New(%q): %v", name, err) } if p.Name() != name { t.Errorf("New(%q).Name() = %q", name, p.Name()) } } if _, err := New("gibtsnicht", ChainConfig{}); err == nil { t.Error("New(\"gibtsnicht\") soll fehlschlagen") } else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") { t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err) } } // TestAvailableOhneTTS: leere PATH-Umgebung → Available false. func TestAvailableOhneTTS(t *testing.T) { emptyPATH(t) if (OSBuiltin{}).Available(context.Background()) { t.Error("Available() = true ohne jedes TTS-Binary, want false") } } // TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler // ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp). func TestSynthesizeOhneTTS(t *testing.T) { emptyPATH(t) _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de") if err == nil { t.Fatal("Synthesize ohne TTS: kein Fehler") } if !errors.Is(err, ErrNoByteOutput) { t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err) } } // TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf // stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme // (-v ) sowie den Text übergeben bekommen haben. func TestSynthesizeMitFakeEspeak(t *testing.T) { dir := t.TempDir() wav := filepath.Join(dir, "probe.wav") if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil { t.Fatal(err) } seen := filepath.Join(dir, "seen.txt") fakeBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`" cat "`+wav+`"`) prependPATH(t, dir) data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo Welt", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if string(data) != string(minimalWAV) { t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV)) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err) } if got := string(args); got != "-v de --stdout Hallo Welt\n" { t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n") } if !(OSBuiltin{}).Available(context.Background()) { t.Error("Available() = false trotz espeak im PATH") } } // TestSynthesizeLeererText ist ein klarer Fehler. func TestSynthesizeLeererText(t *testing.T) { if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil { t.Error("Synthesize(\"\") soll fehlschlagen") } } // TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer // Fehler, kein ErrNoByteOutput. func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) { dir := emptyPATH(t) fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`) _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de") if err == nil { t.Fatal("Synthesize mit crashendem espeak: kein Fehler") } if errors.Is(err, ErrNoByteOutput) { t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err) } if !strings.Contains(err.Error(), "boom") { t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err) } } // TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend // (-w) mit Sprache (-l ) und Text auf. func TestSpeakDirectMitFakeSpdSay(t *testing.T) { dir := emptyPATH(t) seen := filepath.Join(dir, "seen.txt") fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) prependPATH(t, dir) if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo Welt", "de"); err != nil { t.Fatalf("SpeakDirect: %v", err) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err) } if got := string(args); got != "-w -l de Hallo Welt\n" { t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n") } } // TestSpeakDirectOhneSpdSay ist ein klarer Fehler. func TestSpeakDirectOhneSpdSay(t *testing.T) { emptyPATH(t) if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil { t.Error("SpeakDirect ohne spd-say soll fehlschlagen") } } // TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt // ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige // manuelle Abnahme; Standard-Testläufe bleiben stumm). func TestSpeakDirectEchtesSpdSay(t *testing.T) { if os.Getenv("TALK_TEST_REAL_SPD") != "1" { t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen") } if _, err := exec.LookPath("spd-say"); err != nil { t.Skip("spd-say nicht installiert") } if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil { t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err) } } // minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono, // 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation, // unabhängig vom Parser im audio-Paket. var minimalWAV = []byte{ 'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8) 'W', 'A', 'V', 'E', 'f', 'm', 't', ' ', 16, 0, 0, 0, 1, 0, // PCM 1, 0, // mono 0x40, 0x1F, 0, 0, // 8000 Hz 0x80, 0x3E, 0, 0, // ByteRate = 16000 2, 0, // BlockAlign 16, 0, // Bits 'd', 'a', 't', 'a', 8, 0, 0, 0, 0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 Samples }