feat(cli): talk wrap — Befehlsummwicklung mit Ansage und Code-Durchreichung

talk wrap -- <Befehl> [Argumente …] führt den Befehl aus (stdin/stdout/
stderr durchgereicht — stdout gehört allein dem Befehl, talk-Informations-
zeilen gehen auf stderr), misst die Wandzeit und sagt danach das Ergebnis
an: „<label> erfolgreich. Dauer: X.“ bzw. „<label> fehlgeschlagen,
Code N.“, menschlich formatiert (Stunden/Minuten/Sekunden, Einzahl
dekliniert) und lokalisiert de/en nach langdetect des Labels (--lang
überschreibt). Label: config wrap.label bzw. TALK_WRAP_LABEL oder der
Basisname des Befehls.

Exit-Code-Politik: wrap exitet immer mit dem Code des Befehls — scheitert
die Ansage selbst (Daemon lehnt ab, Kette leer), bleibt der Befehls-Code
mit Warning auf stderr (talk-Fehler ≠ Befehls-Fehler). Nicht startbare
Befehle zählen als 127 (Shell-Konvention), Signaltode portabel als 1
(syscall.WaitStatus wäre nicht frei cross-compilierbar; dokumentiert).
Benutzungsfehler von talk (kein Befehl, ungültige say-Flags) scheitern
vor dem Befehlsstart; Config-Fehler nach dem Lauf — Ansage ausgesetzt.

Ansageweg wie say: Daemon-Pfad (enqueue, non-blocking; --wait pollt),
ohne Daemon Degraded Mode; wrap erbt die say-Flags und reicht sie durch.
Dafür bekommen sayViaDaemon/sayDegraded einen info-Writer: say schreibt
Item-ID/muted/discarded weiter auf stdout, wrap auf stderr — Pipes des
Befehls bleiben sauber. Unit-Tests: Dauer-Formatierung (de/en), Ansage-
Texte, Label-Auflösung, In-Prozess-Durchreichung gegen Fake-Provider
(Erfolg/Fehlschlag/127/Label/Ansage-scheitert/stdout-Reinheit).
This commit is contained in:
m3ta-chiron committed 2026-10-18 21:01:07 +02:00
1 parent 671a97ca71
commit 51cd5a686c
8 files changed
+538 -25

No files matched your search

+3 -1
View File
@@ -45,7 +45,9 @@ var commands = []command{
name: "wrap", name: "wrap",
short: "Befehl ausführen und Ergebnis ansagen", short: "Befehl ausführen und Ergebnis ansagen",
args: "-- <Befehl> [Argumente …]", args: "-- <Befehl> [Argumente …]",
detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.", detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer. Das erste „--\" schirmt den Befehl komplett von den talk-Flags ab: `talk wrap -- foo --bar` reicht --bar an foo weiter. Der Exit-Code des Befehls wird durchgereicht; scheitert die Ansage selbst, bleibt er trotzdem. Label der Ansage: config `wrap.label` oder der Basisname des Befehls. wrap erbt die say-Flags (--wait, --now, --priority, --voice, --lang, --provider).",
register: addSayFlags,
handler: runWrap,
}, },
{ {
name: "mute", name: "mute",
+22 -3
View File
@@ -15,10 +15,11 @@ var wantSubcommands = []string{
} }
// wantStubCommands ist wantSubcommands ohne die implementierten Befehle — // wantStubCommands ist wantSubcommands ohne die implementierten Befehle —
// say (Task 02/04) sowie mute/unmute/status/cancel (Task 04, Daemon- // say (Task 02/04), mute/unmute/status/cancel (Task 04, Daemon-Aufrufe)
// Aufrufe) sind real; die restlichen bleiben Stubs bis zu ihren Tasks. // und wrap (Task 07, Befehlsummichtung mit Ansage) sind real; die
// restlichen bleiben Stubs bis zu ihren Tasks.
var wantStubCommands = []string{ var wantStubCommands = []string{
"wrap", "voices", "test", "doctor", "setup", "voices", "test", "doctor", "setup",
} }
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über // runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
@@ -106,6 +107,24 @@ func TestSayHelpZeigtFlags(t *testing.T) {
} }
} }
// TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait)
// und ist kein Stub mehr.
func TestWrapHelpZeigtSayFlags(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
for _, f := range []string{"-wait", "-provider"} {
if !strings.Contains(out.String(), f) {
t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String())
}
}
if strings.Contains(out.String(), "noch nicht implementiert") {
t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String())
}
}
func TestHelpSubcommand(t *testing.T) { func TestHelpSubcommand(t *testing.T) {
t.Run("ohne Argument", func(t *testing.T) { t.Run("ohne Argument", func(t *testing.T) {
var out, errBuf bytes.Buffer var out, errBuf bytes.Buffer
+8 -6
View File
@@ -24,16 +24,18 @@ const (
// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status, // sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking), // dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
// stdout erhält die Item-ID; --wait pollt /status, bis die Ansage weder // info erhält die Item-ID; --wait pollt /status, bis die Ansage weder
// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage — // läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
// stdout sagt „muted". // info sagt „muted". info ist der Writer für Informations-Token (Item-ID,
// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines
// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden.
// //
// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den // handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue // Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus // (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon // (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
// angekommen und wurde bewusst abgelehnt. // angekommen und wurde bewusst abgelehnt.
func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) (code int, handled bool) { func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
client := api.NewClient(cfg.Server.Port) client := api.NewClient(cfg.Server.Port)
probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout) probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
@@ -61,7 +63,7 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde
return 0, false return 0, false
} }
if resp.Muted { if resp.Muted {
fmt.Fprintln(stdout, "muted") fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK, true return exitOK, true
} }
@@ -73,11 +75,11 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde
if resp.Toast { if resp.Toast {
line += " (toast)" line += " (toast)"
} }
fmt.Fprintln(stdout, line) fmt.Fprintln(info, line)
fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast)) fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast))
return exitOK, true return exitOK, true
} }
fmt.Fprintln(stdout, resp.ID) fmt.Fprintln(info, resp.ID)
if !opts.wait { if !opts.wait {
return exitOK, true return exitOK, true
} }
+4 -3
View File
@@ -31,12 +31,13 @@ const (
// dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im // dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im
// Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das // Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das
// Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und // Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und
// Mute weiter, statt zu scheitern. // Mute weiter, statt zu scheitern. info ist der Writer für Informations-
func sayDegraded(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) int { // Token („muted"): say reicht stdout, wrap stderr durch.
func sayDegraded(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if dir, err := config.Dir(getenv); err == nil { if dir, err := config.Dir(getenv); err == nil {
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)} mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
if mute.Muted() { if mute.Muted() {
fmt.Fprintln(stdout, "muted") fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK return exitOK
} }
+6 -3
View File
@@ -1,11 +1,14 @@
// talk ist die CLI für Sprach-Ausgaben (talk 2.0). // talk ist die CLI für Sprach-Ausgaben (talk 2.0).
// //
// Dieser Stand (Task 04): say (und der Default-Aufruf `talk "Text"`) reicht // Dieser Stand (Task 07): say (und der Default-Aufruf `talk "Text"`) reicht
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur // Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded // Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded
// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/ // Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/
// status/cancel sind Daemon-Aufrufe mit plain Output. Die übrigen Befehle // status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- <Befehl>`
// sind Stubs; TUI und weitere Aufgaben folgen in den Tasks 05–09. // führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code
// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die
// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den
// Tasks 08–09.
package main package main
import "os" import "os"
+19 -9
View File
@@ -53,15 +53,10 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
} }
} }
// Validierung vor jedem Daemon-Kontakt: Benutzungsfehler sollen // Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags):
// schnell und ohne Netzwerk-Umwege auffallen. // Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen.
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" { if code, bad := validateSayOptions(opts, stderr); bad {
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang) return code
return exitFailure
}
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
return exitFailure
} }
cfg, err := config.Load(config.Options{Getenv: getenv}) cfg, err := config.Load(config.Options{Getenv: getenv})
@@ -78,6 +73,21 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
return sayDegraded(cfg, text, opts, stdout, stderr) return sayDegraded(cfg, text, opts, stdout, stderr)
} }
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
// en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
// Benutzungsfehler, bereits auf stderr gemeldet.
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
return exitFailure, true
}
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
return exitFailure, true
}
return 0, false
}
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt // pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette. // `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
func pickProvider(flagValue, cfgValue string) string { func pickProvider(flagValue, cfgValue string) string {
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
)
// Kind nicht gefunden — Shell-Konvention 127.
const exitNotFound = 127
// runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- <Befehl>
// [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden
// durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
// Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue,
// non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt
// die say-Flags und reicht sie an die Ansage durch.
//
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
// Fehler ≠ Befehls-Fehler). Scheitert die Ansage, gibt es eine Warning auf
// stderr — der Befehls-Code bleibt. Benutzungsfehler von talk selbst
// (kein Befehl, unbekannte Flags, ungültiges --lang) fallen dagegen VOR
// dem Start des Befehls auf: Er läuft dann gar nicht erst. Nur
// Konfigurations-Fehler (kaputte config.toml/Env) treten nach dem Lauf
// auf — der Befehl bleibt gelaufen, die Ansage wird mit Warning ausgesetzt.
//
// Die Positionsargumente sind die Befehlszeile (Befehl + Argumente); das
// „--" selbst hat parseInterspersed bereits konsumiert — alles dahinter
// kam unverändert durch den Flag-Schutz hindurch.
func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "talk wrap: kein Befehl — Verwendung: talk wrap [Flags] -- <Befehl> [Argumente …]")
return exitUsage
}
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
start := time.Now()
runErr := execChild(args, stdin, stdout, stderr)
duration := time.Since(start)
code := childExitCode(runErr)
if runErr != nil {
// Z. B. Binary nicht gefunden oder Signal-Tod: Der Hinweis gehört
// auf stderr; die Ansage nennt denselben Code, mit dem wrap exitet.
fmt.Fprintf(stderr, "talk wrap: Befehl nicht sauber beendet: %v\n", runErr)
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk wrap: Ansage ausgesetzt — Config: %v (Exit-Code %d des Befehls bleibt)\n", err, code)
return code
}
label := wrapLabel(cfg.Wrap.Label, args[0])
lang := opts.lang
if lang == "" {
lang = langdetect.Detect(label)
}
announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
return code
}
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say:
// Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon
// Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
// auf stderr — stdout gehört allein dem (bereits beendeten) Befehl, damit
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
// werden.
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
warn := func() {
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
}
if code, handled := sayViaDaemon(cfg, text, opts, stderr, stderr); handled {
if code != 0 {
warn()
}
return
}
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
if sayDegraded(cfg, text, opts, stderr, stderr) != exitOK {
warn()
}
}
// execChild startet argv als Kindprozess und wartet auf sein Ende.
// stdin/stdout/stderr werden unverändert durchgereicht — der Befehl
// besitzt die Konsole, talk mischt sich nicht ein (die Ansage selbst geht
// erst NACH dem Ende des Befehls raus). Reines os/exec ohne
// plattformspezifische Aufrufe: läuft unverändert auf Windows, macOS und
// Linux (Kindprozesse startet das jeweilige Betriebssystem-Exec selbst).
func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
}
// childExitCode übersetzt das Run-Ergebnis des Kindprozesses in den
// Exit-Code, mit dem wrap selbst exitet (Durchreich-Vertrag):
//
// - normales Ende: der Exit-Code des Befehls (0 = Erfolg).
// - Ende durch Signal: portabel auf 1 normiert. Die Unix-Konvention
// 128+Signalnummer bräuchte syscall.WaitStatus (Signaltode meldet Go
// als ExitCode -1) und wäre nicht mehr frei cross-compilierbar; die
// Ansage nennt bewusst denselben normierten Code, mit dem wrap exitet.
// - Start fehlgeschlagen (Binary nicht gefunden/nicht ausführbar):
// 127, die Shell-Konvention — der Befehl ist gescheitert, ohne je
// gelaufen zu sein.
func childExitCode(runErr error) int {
if runErr == nil {
return 0
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
if c := ee.ExitCode(); c >= 0 {
return c
}
return exitFailure
}
return exitNotFound
}
// wrapLabel löst das Ansage-Label: config `wrap.label` (Env-Override
// TALK_WRAP_LABEL) schlägt den Basisnamen des Befehls
// (`/usr/bin/make` → „make").
func wrapLabel(cfgLabel, command string) string {
if cfgLabel != "" {
return cfgLabel
}
return filepath.Base(command)
}
// formatDuration macht eine Wandzeit menschenlesbar, lokalisiert de/en:
// „3 Minuten 42 Sekunden" / „3 minutes 42 seconds". Gerundet auf ganze
// Sekunden (halb aufwärts); Nullteile fallen weg („1 Minute"), nur bei
// insgesamt null bleibt „0 Sekunden" stehen. Die Einzahl wird dekliniert
// („1 Sekunde", „1 minute"). Alles über einer Stunde nennt auch Stunden.
func formatDuration(d time.Duration, lang string) string {
type unit struct{ singular, plural string }
units := []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
if lang == "en" {
units = []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
}
secs := int64(d.Round(time.Second) / time.Second)
n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
var parts []string
for i, u := range units {
if n[i] == 0 {
continue
}
word := u.plural
if n[i] == 1 {
word = u.singular
}
parts = append(parts, fmt.Sprintf("%d %s", n[i], word))
}
if len(parts) == 0 {
return fmt.Sprintf("0 %s", units[2].plural)
}
return strings.Join(parts, " ")
}
// announcementText baut die wrap-Ansage nach Brief: Erfolg
// „<label> erfolgreich. Dauer: X." bzw. Misserfolg
// „<label> fehlgeschlagen, Code N." — lokalisiert (englisch:
// „<label> succeeded. Duration: X." / „<label> failed, code N."). Die
// Sprache bestimmt die Spracherkennung des Labels (oder --lang, falls
// gesetzt); alles außer "en" spricht deutsch (langdetect-Default).
func announcementText(label string, code int, d time.Duration, lang string) string {
if lang == "en" {
if code == 0 {
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s failed, code %d.", label, code)
}
if code == 0 {
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
}
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os/exec"
"regexp"
"strings"
"testing"
"time"
)
// TestFormatDuration ist tischgesteuert für die menschliche Dauer-Format
// (de/en): Gerundung auf ganze Sekunden, Nullteile fallen weg, Einzahl
// wird dekliniert, Stunden ab einer Stunde, „0 Sekunden" als Fallback.
func TestFormatDuration(t *testing.T) {
cases := []struct {
d time.Duration
de string
en string
}{
{0, "0 Sekunden", "0 seconds"},
{300 * time.Millisecond, "0 Sekunden", "0 seconds"},
{500 * time.Millisecond, "1 Sekunde", "1 second"}, // halb aufwärts
{1500 * time.Millisecond, "2 Sekunden", "2 seconds"},
{1 * time.Second, "1 Sekunde", "1 second"},
{42 * time.Second, "42 Sekunden", "42 seconds"},
{61 * time.Second, "1 Minute 1 Sekunde", "1 minute 1 second"},
{222 * time.Second, "3 Minuten 42 Sekunden", "3 minutes 42 seconds"},
{3600 * time.Second, "1 Stunde", "1 hour"},
{3601 * time.Second, "1 Stunde 1 Sekunde", "1 hour 1 second"},
{3660 * time.Second, "1 Stunde 1 Minute", "1 hour 1 minute"},
{3720 * time.Second, "1 Stunde 2 Minuten", "1 hour 2 minutes"},
{90061 * time.Second, "25 Stunden 1 Minute 1 Sekunde", "25 hours 1 minute 1 second"},
}
for _, tc := range cases {
if got := formatDuration(tc.d, "de"); got != tc.de {
t.Errorf("formatDuration(%v, de) = %q, want %q", tc.d, got, tc.de)
}
if got := formatDuration(tc.d, "en"); got != tc.en {
t.Errorf("formatDuration(%v, en) = %q, want %q", tc.d, got, tc.en)
}
}
}
// TestAnnouncementText: die Ansage-Texte nach Brief, lokalisiert de/en —
// Erfolg mit Dauer, Misserfolg mit Code (ohne Dauer).
func TestAnnouncementText(t *testing.T) {
d := 222 * time.Second
cases := []struct {
lang string
code int
want string
}{
{"de", 0, "Backup erfolgreich. Dauer: 3 Minuten 42 Sekunden."},
{"de", 2, "Backup fehlgeschlagen, Code 2."},
{"en", 0, "Backup succeeded. Duration: 3 minutes 42 seconds."},
{"en", 2, "Backup failed, code 2."},
}
for _, tc := range cases {
if got := announcementText("Backup", tc.code, d, tc.lang); got != tc.want {
t.Errorf("announcementText(Backup, %d, %v, %s) = %q, want %q", tc.code, d, tc.lang, got, tc.want)
}
}
}
// TestWrapLabel: config-Label schlägt den Basisnamen des Befehls.
func TestWrapLabel(t *testing.T) {
if got := wrapLabel("", "/usr/bin/make"); got != "make" {
t.Errorf("wrapLabel(\"\", /usr/bin/make) = %q, want %q", got, "make")
}
if got := wrapLabel("Deployment", "/usr/bin/terraform"); got != "Deployment" {
t.Errorf("wrapLabel(Deployment, …) = %q, want %q", got, "Deployment")
}
if got := wrapLabel("", "./script.sh"); got != "script.sh" {
t.Errorf("wrapLabel(\"\", ./script.sh) = %q, want %q", got, "script.sh")
}
}
// TestWrapOhneBefehl: `talk wrap` ohne Befehl ist ein klarer
// Benutzungsfehler — der Befehl (nicht vorhanden) läuft natürlich nicht.
func TestWrapOhneBefehl(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap"}, &out, &errBuf)
if code != exitUsage {
t.Fatalf("talk wrap ohne Befehl: Exit-Code = %d, want %d", code, exitUsage)
}
if !strings.Contains(errBuf.String(), "kein Befehl") {
t.Errorf("stderr = %q, will Hinweis auf fehlenden Befehl", errBuf.String())
}
}
// TestWrapValidiertSayFlagsVorDemStart: ungültige geerbte say-Flags
// scheitern VOR dem Befehlsstart (Benutzungsfehler laufen nie den Befehl).
func TestWrapValidiertSayFlagsVorDemStart(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--lang=fr", "--", "true"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk wrap --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "--lang") {
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
}
}
// wrapFakeTTS ist ein ElevenLabs-Fake, der den ANSAGE-TEXT aufzeichnet
// (der CLI-Fake in say_test.go beobachtet nur Pfad/Key — wrap braucht den
// gesprochenen Text als Beobachtung).
func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
t.Helper()
texts = make(chan string, 8)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
texts <- body.Text
w.WriteHeader(status)
_, _ = w.Write([]byte("\xff\xfb\x90\x00 fake-mp3"))
}))
t.Cleanup(ts.Close)
return ts.URL, texts
}
// wrapEnv ist die Degraded-Grundausstattung der In-Prozess-wrap-Tests:
// Daemon garantiert tot (TALK_PORT=1), Kette auf den Fake gepinnt — die
// Ansage läuft deterministisch lokal gegen den Fake (sink null). extra
// ergänzt weitere Env-Werte (ein Aufruf, kein Verdrängen der Basis).
func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
t.Helper()
kv := map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_PROVIDER": "elevenlabs",
"TALK_ELEVENLABS_KEY": "wrap-testkey",
"TALK_ELEVENLABS_ENDPOINT": endpoint,
}
for k, v := range extra {
kv[k] = v
}
setGetenv(t, kv)
}
// needUnixBin überspringt den Test, wenn das Binary fehlt (die
// In-Prozess-Tests starten echte Kinder: true/sh — auf dem Linux-Dev-Host
// und in CI immer vorhanden).
func needUnixBin(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("%s fehlt: %v", name, err)
}
}
// TestWrapDurchreichungErfolg: `talk wrap -- true` (Daemon tot → Degraded,
// Ansage gegen den Fake) exitet 0 und sagt den Erfolg mit Dauer.
func TestWrapDurchreichungErfolg(t *testing.T) {
needUnixBin(t, "true")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "true"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap -- true: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case text := <-texts:
// true läuft Millisekunden — Dauer rund 0; unter Last auch gern
// 1 Sekunde (Einzahl). Nur Struktur verifiziert.
if !regexp.MustCompile(`^true erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(text) {
t.Errorf("Ansage = %q, will „true erfolgreich. Dauer: N Sekunde(n).“", text)
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
}
}
// TestWrapDurchreichungFehlschlag: `talk wrap -- sh -c "exit 3"` exitet 3
// und sagt den Misserfolg mit genau diesem Code.
func TestWrapDurchreichungFehlschlag(t *testing.T) {
needUnixBin(t, "sh")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
if code != 3 {
t.Fatalf("talk wrap -- sh -c 'exit 3': Exit-Code = %d, want 3", code)
}
select {
case text := <-texts:
if text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", text, "sh fehlgeschlagen, Code 3.")
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
}
}
// TestWrapStdoutGehoertBefehl: stdout der wrap-CLI führt NUR die Ausgabe
// des Befehls — die Informationszeilen der Ansage (hier: Degraded-Protokoll
// und Ketten-Logs) gehen auf stderr. Pipes des Befehls bleiben sauber.
func TestWrapStdoutGehoertBefehl(t *testing.T) {
needUnixBin(t, "sh")
url, _ := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "echo kinderausgabe"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap -- sh -c echo: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if out.String() != "kinderausgabe\n" {
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe %q", out.String(), "kinderausgabe\n")
}
}
// TestWrapAnsageScheitertCodeBleibt: Stirbt die Ansage (Endpunkt tot,
// Kette gepinnt → kein Fallback), bleibt der Exit-Code des Befehls
// trotzdem — nur eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
func TestWrapAnsageScheitertCodeBleibt(t *testing.T) {
needUnixBin(t, "sh")
wrapEnv(t, "http://127.0.0.1:1/", nil) // Endpunkt garantiert tot
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 4"}, &out, &errBuf)
if code != 4 {
t.Fatalf("talk wrap (Ansage scheitert): Exit-Code = %d, want 4 (Befehl gewinnt)", code)
}
if !strings.Contains(errBuf.String(), "Ansage fehlgeschlagen") {
t.Errorf("stderr = %q, will Warning, dass die Ansage scheiterte", errBuf.String())
}
}
// TestWrapBefehlNichtGefunden: unbekannter Befehl → Shell-Konvention 127,
// Ansage nennt denselben Code.
func TestWrapBefehlNichtGefunden(t *testing.T) {
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "diesen-befehl-gibt-es-nicht"}, &out, &errBuf)
if code != exitNotFound {
t.Fatalf("talk wrap -- <unbekannt>: Exit-Code = %d, want %d", code, exitNotFound)
}
if !strings.Contains(errBuf.String(), "nicht sauber beendet") {
t.Errorf("stderr = %q, will Hinweis auf den Startfehler", errBuf.String())
}
select {
case text := <-texts:
if text != "diesen-befehl-gibt-es-nicht fehlgeschlagen, Code 127." {
t.Errorf("Ansage = %q, want Code-127-Ansage", text)
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
}
}
// TestWrapLabelAusEnv: TALK_WRAP_LABEL ersetzt den Basisnamen.
func TestWrapLabelAusEnv(t *testing.T) {
needUnixBin(t, "false")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "false"}, &out, &errBuf)
if code != 1 {
t.Fatalf("talk wrap -- false: Exit-Code = %d, want 1", code)
}
select {
case text := <-texts:
if text != "Deployment fehlgeschlagen, Code 1." {
t.Errorf("Ansage = %q, want %q", text, "Deployment fehlgeschlagen, Code 1.")
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
}
}