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
+255
-53
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)
|
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) {
|
func lookupCommand(name string) (command, bool) {
|
||||||
for _, cmd := range commands {
|
for _, cmd := range commands {
|
||||||
if cmd.name == name {
|
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
|
// 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 {
|
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
|
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
|
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
||||||
writeCommandHelp(cmd, stdout)
|
writeCommandHelp(cmd, stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
@@ -188,13 +213,13 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W
|
|||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
if cmd.handler != nil {
|
if cmd.handler != nil {
|
||||||
return cmd.handler(fs.Args(), sayOpts, stdin, stderr)
|
return cmd.handler(positional, sayOpts, stdin, stderr)
|
||||||
}
|
}
|
||||||
return notImplemented(cmd.name, stderr)
|
return notImplemented(cmd.name, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
|
// 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 {
|
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
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")
|
help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
||||||
sayOpts := addSayFlags(fs)
|
sayOpts := addSayFlags(fs)
|
||||||
|
|
||||||
if err := fs.Parse(argv); err != nil {
|
positional, err := parseInterspersed(fs, argv)
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
writeUsage(stdout)
|
writeUsage(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
@@ -219,7 +245,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
writeUsage(stdout)
|
writeUsage(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
return runSay(fs.Args(), sayOpts, stdin, stderr)
|
return runSay(positional, sayOpts, stdin, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||||
|
|||||||
+44
-40
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -29,13 +28,14 @@ type sayOptions struct {
|
|||||||
provider string
|
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 →
|
// Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin →
|
||||||
// Spracherkennung → Provider (Default osbuiltin; Kette folgt Task 03) →
|
// Spracherkennung → Provider-Kette (Task 03: elevenlabs → openai → edge →
|
||||||
// Synthesize → Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern
|
// osbuiltin; --provider/config provider pinnen) → Synthese mit Fallback →
|
||||||
// kann (Linux ohne espeak), springt die dokumentierte Erweiterung
|
// Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern kann (Linux
|
||||||
// provider.DirectSpeaker an (spd-say); mit audio.sink=null wird die
|
// ohne espeak), springt die dokumentierte Erweiterung provider.DirectSpeaker
|
||||||
// Ansage stattdessen verworfen — CI/Server bleiben ton- und fehlerfrei.
|
// 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 {
|
func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int {
|
||||||
text := strings.Join(args, " ")
|
text := strings.Join(args, " ")
|
||||||
if text == "" {
|
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)
|
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang)
|
||||||
return exitFailure
|
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})
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,49 +70,56 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
|
|||||||
return exitFailure
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if !p.Available(ctx) {
|
if err := chain.Speak(ctx, text, lang); err != nil {
|
||||||
fmt.Fprintf(stderr, "talk: Provider %s ist auf diesem System nicht verfügbar (keine passende OS-TTS-Runtime gefunden)\n", p.Name())
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
data, err := p.Synthesize(ctx, text, lang)
|
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
|
||||||
if err != nil {
|
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
|
||||||
if errors.Is(err, provider.ErrNoByteOutput) {
|
func pickProvider(flagValue, cfgValue string) string {
|
||||||
if cfg.Audio.Sink == config.SinkNull {
|
if flagValue != "" {
|
||||||
// null-Sink verwirft die Ansage grundsätzlich — auch die
|
return flagValue
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
return cfgValue
|
||||||
|
}
|
||||||
|
|
||||||
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
// endpointsOf überführt die Provider-Einstellungen der Config in die
|
||||||
if err != nil {
|
// Endpunkt-Map der Kette.
|
||||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
func endpointsOf(cfg config.Config) map[string]string {
|
||||||
return exitFailure
|
endpoints := make(map[string]string, len(cfg.Providers))
|
||||||
|
for name, settings := range cfg.Providers {
|
||||||
|
if settings.Endpoint != "" {
|
||||||
|
endpoints[name] = settings.Endpoint
|
||||||
}
|
}
|
||||||
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
|
return endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
||||||
|
|||||||
+179
-7
@@ -2,18 +2,28 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func setGetenv(t *testing.T, kv map[string]string) {
|
||||||
t.Helper()
|
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
|
old := getenv
|
||||||
getenv = func(k string) string {
|
getenv = func(k string) string {
|
||||||
if v, ok := kv[k]; ok {
|
if v, ok := base[k]; ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -158,17 +168,17 @@ func TestSayUngueltigeSprache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSayUnbekannterProvider: --provider mit noch nicht implementiertem
|
// TestSayUnbekannterProvider: --provider mit unbekanntem Namen ist ein
|
||||||
// Namen ist ein klarer Fehler (Task 03 ergänzt die Kette).
|
// klarer Fehler, der die verfügbaren Provider nennt (Task 03: Kette).
|
||||||
func TestSayUnbekannterProvider(t *testing.T) {
|
func TestSayUnbekannterProvider(t *testing.T) {
|
||||||
withFakeEspeak(t)
|
withFakeEspeak(t)
|
||||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
|
code := runCLI([]string{"--provider=gibtsnicht", "Test"}, &out, &errBuf)
|
||||||
if code != exitFailure {
|
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())
|
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")
|
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