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.
This commit is contained in:
m3ta-chiron committed 2026-10-18 15:39:13 +02:00
1 parent 523e6f4bf2
commit 8d42163aac
5 files changed
+472 -73

No files matched your search

+63 -40
View File
@@ -17,13 +17,16 @@ const (
// command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
// datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
// hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
// anzufassen.
// anzufassen. register hängt die Flags des Befehls an den FlagSet und liefert
// die gebundenen say-Options (derzeit der einzige Befehl mit eigenen Flags;
// erhält ein weiterer Befehl eigene Flags, wird die Signatur verallgemeinert).
type command struct {
name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
short string // Kurzbeschreibung für `talk --help`
args string // Positionsargumente-Hinweis für die Usage-Zeile
detail string // Beschreibung für `talk <Befehl> --help`
register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet
name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
short string // Kurzbeschreibung für `talk --help`
args string // Positionsargumente-Hinweis für die Usage-Zeile
detail string // Beschreibung für `talk <Befehl> --help`
register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
handler func(args []string, o *sayOptions, stdin io.Reader, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
}
// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
@@ -35,6 +38,7 @@ var commands = []command{
args: `"Text"`,
detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
register: addSayFlags,
handler: runSay,
},
{
name: "wrap",
@@ -84,28 +88,39 @@ var commands = []command{
},
}
// addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den
// Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der
// Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04
// (Daemon-Pfad).
func addSayFlags(fs *flag.FlagSet) {
fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist")
fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
fs.String("priority", "normal", "Priorität: low | normal | high")
fs.String("voice", "", "Stimme als provider-spezifische ID")
fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)")
fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Das
// Verhalten: direkter Pfad in Task 02, Daemon-Pfad in Task 04.
//
// Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau
// dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die
// Defaults.
func addSayFlags(fs *flag.FlagSet) *sayOptions {
o := &sayOptions{}
fs.BoolVar(&o.wait, "wait", false, "warten, bis die Ansage fertig abgespielt ist")
fs.BoolVar(&o.now, "now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
fs.StringVar(&o.priority, "priority", "normal", "Priorität: low | normal | high")
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
fs.StringVar(&o.lang, "lang", "", "Sprache: de | en (Default: automatische Erkennung)")
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
return o
}
// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
// Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne
// Prozessstart testbar ist. Rückgabe ist der Exit-Code.
func run(argv []string, stdout, stderr io.Writer) int {
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
// Pfad aus Task 02). Rückgabe ist der Exit-Code.
func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(argv) == 0 {
// Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser
// (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler.
writeUsage(stderr)
return exitUsage
// Kein Befehl und kein Argument: Der Text könnte über stdin
// kommen (Pipe). An einem Terminal gibt es keinen Text — dann
// Benutzungsfehler, damit talk nie auf Eingaben wartet.
if isCharDevice(stdin) {
writeUsage(stderr)
return exitUsage
}
return runSay(nil, &sayOptions{}, stdin, stderr)
}
if argv[0] == "help" {
@@ -122,12 +137,12 @@ func run(argv []string, stdout, stderr io.Writer) int {
}
if cmd, ok := lookupCommand(argv[0]); ok {
return runCommand(cmd, argv[1:], stdout, stderr)
return runCommand(cmd, argv[1:], stdin, stdout, stderr)
}
// Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
// bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub.
return runDefault(argv, stdout, stderr)
// bzw. Root-Flags (--version, --help).
return runDefault(argv, stdin, stdout, stderr)
}
func lookupCommand(name string) (command, bool) {
@@ -140,22 +155,25 @@ func lookupCommand(name string) (command, bool) {
}
// newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
// explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext
// ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird.
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) {
// explizites --help und die gebundenen say-Options. Die Default-Usage wird
// stummgeschaltet, damit Hilfetext ausschließlich über writeCommandHelp
// (eine Quelle) ausgegeben wird.
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOptions) {
fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
fs.SetOutput(out)
fs.Usage = func() {}
var sayOpts *sayOptions
if cmd.register != nil {
cmd.register(fs)
sayOpts = cmd.register(fs)
}
help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
return fs, help
return fs, help, sayOpts
}
// runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub.
func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
fs, help := newCommandFlagSet(cmd, stderr)
// runCommand führt einen benannten Subcommand aus. say ist implementiert
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks.
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
writeCommandHelp(cmd, stdout)
@@ -169,18 +187,21 @@ func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
writeCommandHelp(cmd, stdout)
return exitOK
}
if cmd.handler != nil {
return cmd.handler(fs.Args(), sayOpts, stdin, stderr)
}
return notImplemented(cmd.name, stderr)
}
// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
// `talk --version`, `talk --help` sowie say-Flags vor dem Text.
func runDefault(argv []string, stdout, stderr io.Writer) int {
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {}
showVersion := fs.Bool("version", false, "Version anzeigen")
help := fs.Bool("help", false, "diese Hilfe anzeigen")
addSayFlags(fs)
sayOpts := addSayFlags(fs)
if err := fs.Parse(argv); err != nil {
if errors.Is(err, flag.ErrHelp) {
@@ -198,7 +219,7 @@ func runDefault(argv []string, stdout, stderr io.Writer) int {
writeUsage(stdout)
return exitOK
}
return notImplemented("say", stderr)
return runSay(fs.Args(), sayOpts, stdin, stderr)
}
// versionString rendert die Versionsausgabe: `talk version <version>` plus
@@ -253,7 +274,9 @@ func writeCommandHelp(cmd command, w io.Writer) {
fmt.Fprintf(w, "%s\n\n", cmd.detail)
}
fmt.Fprintln(w, "Flags:")
fs, _ := newCommandFlagSet(cmd, w)
fs, _, _ := newCommandFlagSet(cmd, w)
fs.PrintDefaults()
fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
if cmd.handler == nil {
fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
}
}
+24 -30
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"io"
"strings"
"testing"
)
@@ -13,9 +14,21 @@ var wantSubcommands = []string{
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
}
// wantStubCommands ist wantSubcommands ohne say — say ist seit Task 02
// implementiert, alle anderen bleiben Stubs bis zu ihren Tasks.
var wantStubCommands = []string{
"wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
}
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
// Pipe) — dem üblichen Aufruf ohne Eingabe.
func runCLI(argv []string, stdout, stderr io.Writer) int {
return run(argv, strings.NewReader(""), stdout, stderr)
}
func TestHelpListetAlleSubcommands(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"--help"}, &out, &errBuf)
code := runCLI([]string{"--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
@@ -36,7 +49,7 @@ func TestHelpListetAlleSubcommands(t *testing.T) {
func TestVersionDefault(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"--version"}, &out, &errBuf)
code := runCLI([]string{"--version"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
}
@@ -56,10 +69,10 @@ func TestVersionStringMitCommit(t *testing.T) {
}
func TestSubcommandStubs(t *testing.T) {
for _, name := range wantSubcommands {
for _, name := range wantStubCommands {
t.Run(name, func(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{name}, &out, &errBuf)
code := runCLI([]string{name}, &out, &errBuf)
if code == exitOK {
t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
}
@@ -76,20 +89,9 @@ func TestSubcommandStubs(t *testing.T) {
}
}
func TestDefaultAufrufMapptAufSay(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"Hallo Welt"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure)
}
if !strings.Contains(errBuf.String(), "talk say is not implemented yet") {
t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String())
}
}
func TestSayHelpZeigtFlags(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"say", "--help"}, &out, &errBuf)
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
}
@@ -98,12 +100,15 @@ func TestSayHelpZeigtFlags(t *testing.T) {
t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
}
}
if strings.Contains(out.String(), "noch nicht implementiert") {
t.Errorf("talk say --help soll say nicht mehr als Stub markieren: %q", out.String())
}
}
func TestHelpSubcommand(t *testing.T) {
t.Run("ohne Argument", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"help"}, &out, &errBuf); code != exitOK {
if code := runCLI([]string{"help"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
}
for _, name := range wantSubcommands {
@@ -114,7 +119,7 @@ func TestHelpSubcommand(t *testing.T) {
})
t.Run("mit Befehl", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
if code := runCLI([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
}
if !strings.Contains(out.String(), "talk mute") {
@@ -123,20 +128,9 @@ func TestHelpSubcommand(t *testing.T) {
})
}
func TestOhneArgumenteZeigtUsage(t *testing.T) {
var out, errBuf bytes.Buffer
code := run(nil, &out, &errBuf)
if code != exitUsage {
t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage)
}
if !strings.Contains(errBuf.String(), "Usage") {
t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String())
}
}
func TestUnbekanntesFlag(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"--bogus"}, &out, &errBuf)
code := runCLI([]string{"--bogus"}, &out, &errBuf)
if code != exitUsage {
t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
}
+5 -3
View File
@@ -1,7 +1,9 @@
// talk ist die CLI für Sprach-Ausgaben (talk 2.0).
//
// Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version
// und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09.
// Dieser Stand (Task 02) liefert den say-Pfad end-to-end: Text aus
// Argument oder stdin → Spracherkennung → Provider (osbuiltin) →
// Wiedergabe über einen Audio-Sink (oto/null). Die übrigen Befehle sind
// Stubs; Daemon, Kette und TUI folgen in den Tasks 03–09.
package main
import "os"
@@ -17,5 +19,5 @@ var (
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"code.m3ta.dev/m3tam3re/talk/internal/audio"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
"code.m3ta.dev/m3tam3re/talk/internal/provider"
)
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK
// etc., ohne den Prozess-Environment anzufassen). Default: os.Getenv.
var getenv = os.Getenv
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
// `talk "Text"`).
type sayOptions struct {
wait bool
now bool
priority string
voice string
lang string
provider string
}
// runSay ist der komplette Ansage-Pfad von Task 02 (direkt und blockierend;
// Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin →
// Spracherkennung → Provider (Default osbuiltin; Kette folgt Task 03) →
// Synthesize → Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern
// kann (Linux ohne espeak), springt die dokumentierte Erweiterung
// provider.DirectSpeaker an (spd-say); mit audio.sink=null wird die
// Ansage stattdessen verworfen — CI/Server bleiben ton- und fehlerfrei.
func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int {
text := strings.Join(args, " ")
if text == "" {
if isCharDevice(stdin) {
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
return exitUsage
}
b, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
return exitFailure
}
text = strings.TrimSpace(string(b))
if text == "" {
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
return exitFailure
}
}
lang := opts.lang
if lang == "" {
lang = langdetect.Detect(text)
}
if lang != "de" && lang != "en" {
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang)
return exitFailure
}
if opts.voice != "" {
fmt.Fprintln(stderr, "talk: Hinweis: --voice wirkt ab Task 03 (Voice-Registry) und wird für diese Ansage ignoriert")
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
p, err := provider.New(opts.provider)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
ctx := context.Background()
if !p.Available(ctx) {
fmt.Fprintf(stderr, "talk: Provider %s ist auf diesem System nicht verfügbar (keine passende OS-TTS-Runtime gefunden)\n", p.Name())
return exitFailure
}
data, err := p.Synthesize(ctx, text, lang)
if err != nil {
if errors.Is(err, provider.ErrNoByteOutput) {
if cfg.Audio.Sink == config.SinkNull {
// null-Sink verwirft die Ansage grundsätzlich — auch die
// Direktansage würde Ton erzeugen und entfällt somit.
fmt.Fprintln(stderr, "talk: Ansage verworfen (audio.sink=null)")
return exitOK
}
if ds, ok := p.(provider.DirectSpeaker); ok {
if err := ds.SpeakDirect(ctx, text, lang); err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
}
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
sink, err := audio.NewSink(cfg.Audio.Sink)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
if err := sink.Play(ctx, data); err != nil {
fmt.Fprintf(stderr, "talk: Wiedergabe über %s fehlgeschlagen: %v\n", sink.Name(), err)
return exitFailure
}
return exitOK
}
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
func isCharDevice(r io.Reader) bool {
f, ok := r.(*os.File)
if !ok {
return false
}
st, err := f.Stat()
if err != nil {
return false
}
return st.Mode()&os.ModeCharDevice != 0
}
+247
View File
@@ -0,0 +1,247 @@
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")
}
}