Files
talk/cmd/talk/say_test.go
T
m3ta-chiron 8d42163aac feat(cli): say-Pfad end-to-end — erste Ansage über OS-TTS
Text aus Argument oder stdin → langdetect → Provider (Default osbuiltin;
--provider existiert, Kette folgt Task 03) → Synthesize → Sink.Play,
blockierend. Ohne byte-fähiges OS-TTS (Linux ohne espeak): Direktansage
über DirectSpeaker (spd-say); mit audio.sink=null wird die Ansage
stattdessen verworfen — CI/Server laufen tonlos mit Exit 0 durch.
run() bekommt stdin als Reader-Seam; an einem Terminal ohne Text gibt
es einen Benutzungsfehler statt blockierendem Warten. say-Hilfe zeigt
nicht mehr den Stub-Hinweis; alle anderen Befehle bleiben Stubs.
2026-10-18 15:39:13 +02:00

248 lines
8.5 KiB
Go

package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// setGetenv überschreibt die Env-Seam der CLI für einen Test.
func setGetenv(t *testing.T, kv map[string]string) {
t.Helper()
old := getenv
getenv = func(k string) string {
if v, ok := kv[k]; ok {
return v
}
return ""
}
t.Cleanup(func() { getenv = old })
}
// fakeTTSBin schreibt ein ausführbares Shell-Skript namens name in dir
// (gleicher Mechanismus wie im provider-Paket: echter Prozess, PATH-
// Manipulation, kein Mock-Framework).
func fakeTTSBin(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)
}
}
// withFakeEspeak stellt ein fake espeak bereit, das WAV-Bytes auf stdout
// schreibt und den Aufruf in seen protokolliert. Zurück kommt der Pfad der
// Protokolldatei.
func withFakeEspeak(t *testing.T) string {
t.Helper()
dir := t.TempDir()
wav := filepath.Join(dir, "probe.wav")
if err := os.WriteFile(wav, []byte("RIFFxxxxWAVEfmt "), 0o644); err != nil {
t.Fatal(err)
}
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`"
cat "`+wav+`"`)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return seen
}
// TestSayArgumentMitNullSink: `talk "Hallo Welt"` läuft mit
// TALK_AUDIO_SINK=null tonlos durch (Abnahmekriterium) — Synthese läuft
// (fake espeak wurde gerufen), die Wiedergabe wird verworfen.
func TestSayArgumentMitNullSink(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
if code != exitOK {
t.Fatalf(`talk "Hallo Welt" (sink=null): Exit-Code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
}
}
// TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
// Default-Pfad.
func TestSaySubcommandWieDefault(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"say", "Hallo"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk say: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err)
}
}
// TestSayViaStdin: ohne Text-Argument wird stdin gelesen.
func TestSayViaStdin(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := run([]string{}, strings.NewReader("Hallo Welt\n"), &out, &errBuf)
if code != exitOK {
t.Fatalf("talk < stdin: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
}
// TestSaySprachwahl: englisches Argument übergibt -v en an den Provider,
// deutsches -v de; --lang überschreibt die Erkennung.
func TestSaySprachwahl(t *testing.T) {
cases := []struct {
name string
argv []string
want string
}{
{"erkannt de", []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
{"erkannt en", []string{"The build is done"}, "-v en --stdout The build is done"},
{"flag pinnt en", []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI(tc.argv, &out, &errBuf); code != exitOK {
t.Fatalf("Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake espeak nicht gerufen: %v", err)
}
if got := strings.TrimSpace(string(args)); got != tc.want {
t.Errorf("espeak ARGS = %q, want %q", got, tc.want)
}
})
}
}
// TestSayOhneText: leeres stdin und kein Argument → klarer Fehler.
func TestSayOhneText(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI(nil, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk ohne Text: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "kein Text") {
t.Errorf("stderr = %q, will klaren Hinweis auf fehlenden Text", errBuf.String())
}
}
// TestSayUngueltigeSprache: --lang mit Unsinn ist ein klarer Fehler.
func TestSayUngueltigeSprache(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--lang=fr", "Bonjour"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "--lang") {
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
}
}
// TestSayUnbekannterProvider: --provider mit noch nicht implementiertem
// Namen ist ein klarer Fehler (Task 03 ergänzt die Kette).
func TestSayUnbekannterProvider(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk --provider=elevenlabs: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "elevenlabs") || !strings.Contains(errBuf.String(), "osbuiltin") {
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
}
}
// TestSayUngueltigerSink: TALK_AUDIO_SINK=Müll ist ein klarer Fehler aus
// der Config-Validierung.
func TestSayUngueltigerSink(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "mpv"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk mit TALK_AUDIO_SINK=mpv: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "audio.sink") {
t.Errorf("stderr = %q, will audio.sink-Validierungsfehler", errBuf.String())
}
}
// TestSayOhneTTS: ohne jedes TTS-Binary ist Available false → klarer
// Fehler, kein Panik.
func TestSayOhneTTS(t *testing.T) {
t.Setenv("PATH", t.TempDir())
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk ohne TTS: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
t.Errorf("stderr = %q, will Verfügbarkeits-Fehler", errBuf.String())
}
}
// TestSayDirektesFallbeckenOhneEspeak: Linux ohne espeak (nur fake spd-say)
// und sink=null verwirft tonlos mit Exit 0 — das spd-say wird NICHT gerufen
// (null-Sink darf nie Ton erzeugen).
func TestSayDirektesFallbeckenOhneEspeak(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
// PATH NUR auf das fake-spd-say-Verzeichnis: kein echtes espeak, kein
// echtes spd-say dazwischen.
t.Setenv("PATH", dir)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk (Linux ohne espeak, sink=null): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Sink hätte die Ansage verworfen müssen")
}
if !strings.Contains(errBuf.String(), "verworfen") {
t.Errorf("stderr = %q, will Verwurf-Hinweis", errBuf.String())
}
}
// TestSayDirektesFallbeckenMitOtoSink: ohne espeak, mit (fake) spd-say und
// dem Default-Sink oto muss die Direktansage über spd-say laufen.
func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
setGetenv(t, nil) // Default-Sink oto, keine Env-Overrides
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk (Direktansage): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
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")
}
}