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.
248 lines
8.5 KiB
Go
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")
|
|
}
|
|
}
|