diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index cfa91f9..d72134e 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -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 --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 --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 ` 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).") + } } diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index 6b83aa2..8937c82 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -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) } diff --git a/cmd/talk/main.go b/cmd/talk/main.go index 41cd357..b79e5de 100644 --- a/cmd/talk/main.go +++ b/cmd/talk/main.go @@ -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)) } diff --git a/cmd/talk/say.go b/cmd/talk/say.go new file mode 100644 index 0000000..d2fc1ab --- /dev/null +++ b/cmd/talk/say.go @@ -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 +} diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go new file mode 100644 index 0000000..a661101 --- /dev/null +++ b/cmd/talk/say_test.go @@ -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") + } +}