feat(cli): say-Pfad über die Provider-Kette
runSay baut jetzt die Kette aus Config + Flags (Pin: --provider schlägt config provider; --voice wird über VoicePinner durchgepinnt) und ruft chain.Speak — Synthese mit Fallback, Wiedergabe über den Sink, Direkt- ansage inklusive tonlosem Verwerfen am null-Sink steckt komplett in der Kette (wiederverwendbar für den Daemon aus Task 04). Zusätzlich: Flags dürfen jetzt überall stehen — Go-stdlib-flag stoppt bei dem ersten Nicht-Flag-Argument, die Abnahme-Aufrufform ist aber `talk "Test" --provider=elevenlabs`. parseInterspersed parst wiederholt über den Rest; Positionsargumente (der Text) bleiben vollständig. Tests: Cloud-Provider-End-to-End über die Env-Seam gegen httptest-Fakes, --voice im Request-Pfad sichtbar, 500-Fallback bis osbuiltin auf CLI- Ebene, Flag-nach-Text-Regression.
This commit is contained in:
1 parent
91cd0ec8da
commit
28b4e558c3
3 files changed
+258
-56
No files matched your search
+32
-6
@@ -145,6 +145,29 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return runDefault(argv, stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
// parseInterspersed parst Flags, die überall in argv stehen dürfen. Die
|
||||
// Go-stdlib-flag stoppt beim ersten Nicht-Flag-AArgument; die CLI-Surface
|
||||
// verlangt aber `talk "Text" --provider=x` (Abnahmekriterium Task 03)
|
||||
// genauso wie `talk --provider=x "Text"`. Trick: Parse über den Rest
|
||||
// wiederholen und jeweils das Positionsargument vor dem Weiterparsen
|
||||
// entnehmen. Fehler (unbekannte Flags, -h) kommen wie gewohnt vom FlagSet.
|
||||
func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
var positional []string
|
||||
rest := argv
|
||||
for {
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
return positional, nil
|
||||
}
|
||||
// args[0] ist das Argument, an dem Parse gestoppt hat.
|
||||
positional = append(positional, args[0])
|
||||
rest = args[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func lookupCommand(name string) (command, bool) {
|
||||
for _, cmd := range commands {
|
||||
if cmd.name == name {
|
||||
@@ -171,10 +194,12 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp
|
||||
}
|
||||
|
||||
// runCommand führt einen benannten Subcommand aus. say ist implementiert
|
||||
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks.
|
||||
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks. Flags dürfen auch
|
||||
// nach dem Text stehen (parseInterspersed).
|
||||
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 {
|
||||
positional, err := parseInterspersed(fs, args)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
||||
writeCommandHelp(cmd, stdout)
|
||||
return exitOK
|
||||
@@ -188,13 +213,13 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W
|
||||
return exitOK
|
||||
}
|
||||
if cmd.handler != nil {
|
||||
return cmd.handler(fs.Args(), sayOpts, stdin, stderr)
|
||||
return cmd.handler(positional, 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.
|
||||
// `talk --version`, `talk --help` sowie say-Flags vor oder nach dem Text.
|
||||
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
@@ -203,7 +228,8 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
||||
sayOpts := addSayFlags(fs)
|
||||
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
positional, err := parseInterspersed(fs, argv)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
writeUsage(stdout)
|
||||
return exitOK
|
||||
@@ -219,7 +245,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
writeUsage(stdout)
|
||||
return exitOK
|
||||
}
|
||||
return runSay(fs.Args(), sayOpts, stdin, stderr)
|
||||
return runSay(positional, sayOpts, stdin, stderr)
|
||||
}
|
||||
|
||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||
|
||||
+47
-43
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -29,13 +28,14 @@ type sayOptions struct {
|
||||
provider string
|
||||
}
|
||||
|
||||
// runSay ist der komplette Ansage-Pfad von Task 02 (direkt und blockierend;
|
||||
// runSay ist der komplette Ansage-Pfad (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.
|
||||
// Spracherkennung → Provider-Kette (Task 03: elevenlabs → openai → edge →
|
||||
// osbuiltin; --provider/config provider pinnen) → Synthese mit Fallback →
|
||||
// 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 == "" {
|
||||
@@ -63,9 +63,6 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
|
||||
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 {
|
||||
@@ -73,51 +70,58 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
p, err := provider.New(opts.provider)
|
||||
// Sink zuerst: Die Kette braucht ihn als Player (Bytes-Wiedergabe) und
|
||||
// für die null-Entscheidung bei der Direktansage.
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
chain, err := provider.NewChain(provider.ChainConfig{
|
||||
Pin: pickProvider(opts.provider, cfg.Provider),
|
||||
Voice: opts.voice,
|
||||
Voices: cfg.Voice,
|
||||
Endpoints: endpointsOf(cfg),
|
||||
Model: cfg.Providers["openai"].Model,
|
||||
Getenv: getenv,
|
||||
Player: sink,
|
||||
Log: stderr,
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := chain.Speak(ctx, text, lang); err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
||||
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
|
||||
}
|
||||
|
||||
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
|
||||
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
|
||||
func pickProvider(flagValue, cfgValue string) string {
|
||||
if flagValue != "" {
|
||||
return flagValue
|
||||
}
|
||||
return cfgValue
|
||||
}
|
||||
|
||||
// endpointsOf überführt die Provider-Einstellungen der Config in die
|
||||
// Endpunkt-Map der Kette.
|
||||
func endpointsOf(cfg config.Config) map[string]string {
|
||||
endpoints := make(map[string]string, len(cfg.Providers))
|
||||
for name, settings := range cfg.Providers {
|
||||
if settings.Endpoint != "" {
|
||||
endpoints[name] = settings.Endpoint
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+179
-7
@@ -2,18 +2,28 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setGetenv überschreibt die Env-Seam der CLI für einen Test.
|
||||
// setGetenv überschreibt die Env-Seam der CLI für einen Test. Grundaus-
|
||||
// stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert
|
||||
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
|
||||
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
||||
// anfassen. Wer edge bewusst anders braucht, überschreibt den Key.
|
||||
func setGetenv(t *testing.T, kv map[string]string) {
|
||||
t.Helper()
|
||||
base := map[string]string{"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/"}
|
||||
for k, v := range kv {
|
||||
base[k] = v
|
||||
}
|
||||
old := getenv
|
||||
getenv = func(k string) string {
|
||||
if v, ok := kv[k]; ok {
|
||||
if v, ok := base[k]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
@@ -158,17 +168,17 @@ func TestSayUngueltigeSprache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUnbekannterProvider: --provider mit noch nicht implementiertem
|
||||
// Namen ist ein klarer Fehler (Task 03 ergänzt die Kette).
|
||||
// TestSayUnbekannterProvider: --provider mit unbekanntem Namen ist ein
|
||||
// klarer Fehler, der die verfügbaren Provider nennt (Task 03: 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)
|
||||
code := runCLI([]string{"--provider=gibtsnicht", "Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk --provider=elevenlabs: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk --provider=gibtsnicht: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "elevenlabs") || !strings.Contains(errBuf.String(), "osbuiltin") {
|
||||
if !strings.Contains(errBuf.String(), "gibtsnicht") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
|
||||
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -250,3 +260,165 @@ func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
|
||||
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeCloudServer startet einen httptest-HTTP-Fake, der status mit body
|
||||
// antwortet und den letzten Request aufzeichnet (Struktur wie im
|
||||
// provider-Paket; die CLI-Tests beobachten nur extern: Exit-Code, stderr,
|
||||
// ob der Fake gerufen wurde und mit welchem Pfad).
|
||||
type fakeCloudReq struct {
|
||||
path string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs chan fakeCloudReq) {
|
||||
t.Helper()
|
||||
reqs = make(chan fakeCloudReq, 8)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqs <- fakeCloudReq{path: r.URL.Path, apiKey: r.Header.Get("xi-api-key")}
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL, reqs
|
||||
}
|
||||
|
||||
// TestSayCloudProviderMitFake: `talk "Text" --provider=elevenlabs` mit Key
|
||||
// + Fake-Endpunkt und sink=null läuft mit Exit 0 durch — Synthese lief
|
||||
// (Fake gerufen, korrekter Pfad inkl. neutraler Default-Stimme, Key im
|
||||
// Header), Wiedergabe verworfen.
|
||||
func TestSayCloudProviderMitFake(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_KEY": "cli-testkey",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url,
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk --provider=elevenlabs (Fake): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; req.path != want {
|
||||
t.Errorf("Pfad = %q, want %q (neutrales Default)", req.path, want)
|
||||
}
|
||||
if req.apiKey != "cli-testkey" {
|
||||
t.Errorf("xi-api-key = %q", req.apiKey)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
|
||||
t.Errorf("stderr = %q, will Verwurf-Zeile", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayVoiceFlagAmCloudProvider: --voice landet im Request-Pfad.
|
||||
func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_KEY": "k",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url,
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MeineStimme", "Test"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("Exit-Code = %d, stderr = %q", code, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if want := "/v1/text-to-speech/MeineStimme"; req.path != want {
|
||||
t.Errorf("Pfad = %q, want %q", req.path, want)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayCloudOhneKey: gepinnter Cloud-Provider ohne Key → klarer Fehler
|
||||
// (Stufe nicht verfügbar, Exit 1).
|
||||
func TestSayCloudOhneKey(t *testing.T) {
|
||||
url, _ := fakeCloudServer(t, 200, []byte("x"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url, // Endpunkt, aber kein Key
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
|
||||
t.Errorf("stderr = %q, will Verfügbarkeits-Hinweis", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayCloud500FaelltAufOsbuiltin: gepinnt wäre kurzfristig — hier ohne
|
||||
// Pin: elevenlabs (Fake, 500) → openai (kein Key) → edge (tot) → osbuiltin
|
||||
// (fake espeak) liefert die Bytes. Der CLI-Ketten-Fallback ist end-to-end
|
||||
// über die Env-Seam beobachtbar.
|
||||
func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
|
||||
url, _ := fakeCloudServer(t, 500, []byte("boom"))
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_KEY": "k",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url,
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Ketten Test"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk mit 500-Cloud-Stufe: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen (Kette nicht bis osbuiltin gefallen): %v", err)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "Fallback") {
|
||||
t.Errorf("stderr = %q, will sichtbaren 500-Fallback", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayFlagNachDemText: `talk "Text" --provider=…` (Flag NACH dem Text,
|
||||
// Abnahme-Aufrufform) pinnt den Provider — nur die gepinnte Stufe wird
|
||||
// angefragt.
|
||||
func TestSayFlagNachDemText(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_KEY": "k",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url,
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Test ansage", "--provider=elevenlabs"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk \"Text\" --provider=elevenlabs: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if !strings.Contains(req.path, "/v1/text-to-speech/") {
|
||||
t.Errorf("Pfad = %q, want TTS-Pfad", req.path)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
}
|
||||
// Zwei Argumente mit verteilten Flags: Text bleibt vollständig.
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
code = runCLI([]string{"Erster", "--lang=en", "zweiter Teil"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk Erster --lang=en zweiter Teil: Exit-Code = %d, stderr = %q", code, 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 != "-v en --stdout Erster zweiter Teil" {
|
||||
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout Erster zweiter Teil")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user