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:
1 parent
671a97ca71
commit
51cd5a686c
8 files changed
+541
-28
No files matched your search
+6
-4
@@ -42,10 +42,12 @@ var commands = []command{
|
|||||||
handler: runSay,
|
handler: runSay,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user