Files
talk/.superpowers/sdd/plan/review-30bd535..acfba19.diff
T

1286 lines
61 KiB
Diff
Raw Normal View History

# Review package: 30bd535..HEAD
## Commits
acfba19 docs(readme): talk wrap dokumentieren — --Semantik, Label, Exit-Codes
6877d64 test: Prozessgrenzen-Tests für talk wrap an echter CLI + Daemon
51cd5a6 feat(cli): talk wrap — Befehlsummwicklung mit Ansage und Code-Durchreichung
671a97c fix(cli): Erstes „--“ schirmt Positionsargumente vollständig ab
a3424c3 feat(config): Ansage-Label wrap.label mit Env-Override TALK_WRAP_LABEL
## Files changed
README.md | 47 ++++++-
cmd/talk/cli.go | 39 +++++-
cmd/talk/cli_test.go | 25 +++-
cmd/talk/daemon.go | 14 +-
cmd/talk/degraded.go | 7 +-
cmd/talk/main.go | 9 +-
cmd/talk/parse_test.go | 98 ++++++++++++++
cmd/talk/say.go | 28 ++--
cmd/talk/wrap.go | 195 ++++++++++++++++++++++++++++
cmd/talk/wrap_test.go | 281 +++++++++++++++++++++++++++++++++++++++++
internal/config/config.go | 15 +++
internal/config/config_test.go | 52 ++++++++
test/wrap_test.go | 168 ++++++++++++++++++++++++
13 files changed, 947 insertions(+), 31 deletions(-)
## Diff
README.md --- 1/2 --- Text
30 30
31 **Hinweis:** In diesem Stand sind i 31 **Hinweis:** In diesem Stand sind i
.. mplementiert: `say` (und `talk "Tex .. mplementiert: `say` (und `talk "Tex
.. t"`) .. t"`)
32 mit der kompletten Provider-Kette u 32 mit der kompletten Provider-Kette u
.. nd Daemon-Anbindung (non-blocking ü .. nd Daemon-Anbindung (non-blocking ü
.. ber .. ber
33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm
.. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel` sowie `wra
.. en .. p`.
34 Befehle sind Stubs und melden `not 34 Die übrigen Befehle sind Stubs und
.. implemented yet` (Exit-Code ≠ 0). .. melden `not implemented yet` (Exit-
.. .. Code
.. 35 ≠ 0).
35 36
36 ## Daemon talkd (Task 04–06) 37 ## Daemon talkd (Task 04–06)
37 38
README.md --- 2/2 --- Text
219 220 `TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml)
220 221 schaltet jede Tongabe ab — für CI und Server.
221 222
... 223 ## `talk wrap` — Befehle annoncieren (Task 07)
... 224
... 225 `talk wrap -- <Befehl> [Argumente …]` führt den Befehl aus und macht das
... 226 Ergebnis hörbar: stdin/stdout/stderr werden durchgereicht — stdout gehört
... 227 allein dem Befehl, Informationszeilen von talk (z. B. die Item-ID der
... 228 Ansage) gehen auf stderr, damit Pipes und Redirects sauber bleiben. Die
... 229 Wandzeit wird gemessen und nach dem Ende des Befehls angesagt:
... 230
... 231 - Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden."
... 232 - Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer)
... 233
... 234 ```console
... 235 talk wrap -- sleep 2
... 236 talk wrap --wait -- make test
... 237 ```
... 238
... 239 - **`--` schirmt den Befehl komplett ab:** Das erste allein stehende `--`
... 240 beendet die talk-Flag-Auswertung — alles danach geht unverändert an den
... 241 Befehl. `talk wrap -- foo --bar` reicht `--bar` an foo weiter, statt es
... 242 als talk-Flag zu fehlzuinterpretieren. talk-Flags für die Ansage stehen
... 243 deshalb davor (`talk wrap --wait -- …`); ohne `--` gelten die üblichen
... 244 Regeln (Flags überall, auch nach Positionalargumenten).
... 245 - **Label** der Ansage: `wrap.label` in der config.toml (Env
... 246 `TALK_WRAP_LABEL` schlägt die Datei) oder der Basisname des Befehls
... 247 (`/usr/bin/make` → „make"). Die Sprache erkennt langdetect am Label
... 248 (de/en, deutscher Default); `--lang` überschreibt.
... 249 - **Exit-Code-Politik:** wrap exitet immer mit dem Code des Befehls —
... 250 scheitert die Ansage selbst, bleibt der Befehls-Code trotzdem (nur eine
... 251 Warning auf stderr; talk-Fehler ≠ Befehls-Fehler). Nicht startbare
... 252 Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
... 253 Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
... 254 Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
... 255 - **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking;
... 256 `--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
... 257 wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
... 258 `--lang`, `--provider`) und reicht sie an die Ansage durch.
... 259
... 260 ```toml
... 261 [wrap]
... 262 label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)
... 263 ```
... 264
222 265 ## Entwicklung
223 266
224 267 ```console
cmd/talk/cli.go --- 1/3 --- Go
5 5 "flag"
6 6 "fmt"
7 7 "io"
. 8 "slices"
8 9 )
9 10
10 11 // Exit-Codes.
cmd/talk/cli.go --- 2/3 --- Go
44 name: "wrap", 45 name: "wrap",
45 short: "Befehl ausführen u 46 short: "Befehl ausführen
.. nd Ergebnis ansagen", .. und Ergebnis ansagen",
46 args: "-- <Befehl> [Argum 47 args: "-- <Befehl> [Arg
.. ente …]", .. umente …]",
47 detail: "Führt den Befehl a 48 detail: "Führt den Befehl
.. us (stdin/stdout/stderr werden durc .. aus (stdin/stdout/stderr werden du
.. hgereicht) und spricht danach Erfol .. rchgereicht) und spricht danach Erf
.. g bzw. Misserfolg und Dauer.", .. olg bzw. Misserfolg und Dauer. Das
.. .. erste „--\" schirmt den Befehl komp
.. .. lett von den talk-Flags ab: `talk w
.. .. rap -- foo --bar` reicht --bar an f
.. .. oo weiter. Der Exit-Code des Befehl
.. .. s wird durchgereicht; scheitert die
.. .. Ansage selbst, bleibt er trotzdem.
.. .. Label der Ansage: config `wrap.lab
.. .. el` oder der Basisname des Befehls.
.. .. wrap erbt die say-Flags (--wait, -
.. .. -now, --priority, --voice, --lang,
.. .. --provider).",
.. 49 register: addSayFlags,
.. 50 handler: runWrap,
48 }, 51 },
49 { 52 {
50 name: "mute", 53 name: "mute",
cmd/talk/cli.go --- 3/3 --- Go
151 } 154 }
152 155
153 // parseInterspersed parst Flags, d 156 // parseInterspersed parst Flags, d
... ie überall in argv stehen dürfen. D ... ie überall in argv stehen dürfen. D
... ie ... ie
154 // Go-stdlib-flag stoppt beim erste 157 // Go-stdlib-flag stoppt beim erste
... n Nicht-Flag-AArgument; die CLI-Sur ... n Nicht-Flag-Argument; die CLI-Surf
... face ... ace
155 // verlangt aber `talk "Text" --pro 158 // verlangt aber `talk "Text" --pro
... vider=x` (Abnahmekriterium Task 03) ... vider=x` (Abnahmekriterium Task 03)
156 // genauso wie `talk --provider=x " 159 // genauso wie `talk --provider=x "
... Text"`. Trick: Parse über den Rest ... Text"`. Trick: Parse über den Rest
157 // wiederholen und jeweils das Posi 160 // wiederholen und jeweils das Posi
... tionsargument vor dem Weiterparsen ... tionsargument vor dem Weiterparsen
158 // entnehmen. Fehler (unbekannte Fl 161 // entnehmen. Fehler (unbekannte Fl
... ags, -h) kommen wie gewohnt vom Fla ... ags, -h) kommen wie gewohnt vom Fla
... gSet. ... gSet.
... 162 //
... 163 // Seit Task 07 schirmt das erste „
... ... --" (als eigenständiges Argument) a
... ... lles
... 164 // dahinter vollständig ab — die St
... ... dlib-Terminator-Semantik, die der
... 165 // Interspersed-Loop zuvor aushebel
... ... te: Er nahm nach jedem Positionsarg
... ... ument
... 166 // das Parsen wieder auf und fraß d
... ... amit Flags, die für den NACHFOLGEND
... ... EN
... 167 // Befehl gemeint waren (`talk wrap
... ... -- foo --bar` wäre an --bar als
... 168 // vermeintlichem talk-Flag geschei
... ... tert). Jetzt wird argv am ersten „-
... ... -"
... 169 // getrennt: Nur der Teil davor wir
... ... d interspersed geparst, der Rest is
... ... t
... 170 // bedingungslos Positionsargument
... ... — für say (`talk -- "Text --flag"`)
... 171 // genauso wie für wrap (`talk wrap
... ... -- foo --bar`). Die Abnahme-Form
... 172 // `talk "Text" --provider=x` (Flag
... ... nach dem Text, ohne „--") ist davo
... ... n
... 173 // unberührt. Ein „--" als Flag-Wer
... ... t (unmittelbar nach --voice) trifft
... 174 // diese Trennung nicht, weil nur e
... ... in allein stehendes „--" trennt.
159 func parseInterspersed(fs *flag.Fla 175 func parseInterspersed(fs *flag.Fla
... gSet, argv []string) ([]string, err ... gSet, argv []string) ([]string, err
... or) { ... or) {
... 176 if i := slices.Index(argv, "--"
... ... ); i >= 0 {
... 177 positional, err := parseInt
... ... erspersedLoop(fs, argv[:i])
... 178 if err != nil {
... 179 return nil, err
... 180 }
... 181 return append(positional, a
... ... rgv[i+1:]...), nil
... 182 }
... 183 return parseInterspersedLoop(fs
... ... , argv)
... 184 }
... 185
... 186 // parseInterspersedLoop ist der In
... ... terspersed-Durchlauf — argv enthält
... ... kein
... 187 // allein stehendes „--" mehr (pars
... ... eInterspersed hat davor getrennt).
... 188 func parseInterspersedLoop(fs *flag
... ... .FlagSet, argv []string) ([]string,
... ... error) {
160 var positional []string 189 var positional []string
161 rest := argv 190 rest := argv
162 for { 191 for {
cmd/talk/cli_test.go --- 1/2 --- Go
15 } 15 }
16 16
17 // wantStubCommands ist wantSubcomm 17 // wantStubCommands ist wantSubcomm
.. ands ohne die implementierten Befeh .. ands ohne die implementierten Befeh
.. le — .. le —
18 // say (Task 02/04) sowie mute/unmu 18 // say (Task 02/04), mute/unmute/st
.. te/status/cancel (Task 04, Daemon- .. atus/cancel (Task 04, Daemon-Aufruf
.. .. e)
.. 19 // und wrap (Task 07, Befehlsummich
.. .. tung mit Ansage) sind real; die
19 // Aufrufe) sind real; die restlich 20 // restlichen bleiben Stubs bis zu
.. en bleiben Stubs bis zu ihren Tasks .. ihren Tasks.
.. . ..
20 var wantStubCommands = []string{ 21 var wantStubCommands = []string{
21 "wrap", "voices", "test", "doct 22 "voices", "test", "doctor", "se
.. or", "setup", .. tup",
22 } 23 }
23 24
24 // runCLI ist der Test-Einstieg in 25 // runCLI ist der Test-Einstieg in
.. run mit leerem stdin (kein Text übe .. run mit leerem stdin (kein Text übe
.. r .. r
cmd/talk/cli_test.go --- 2/2 --- Go
106 107 }
107 108 }
... 109
... 110 // TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait)
... 111 // und ist kein Stub mehr.
... 112 func TestWrapHelpZeigtSayFlags(t *testing.T) {
... 113 var out, errBuf bytes.Buffer
... 114 code := runCLI([]string{"wrap", "--help"}, &out, &errBuf)
... 115 if code != exitOK {
... 116 t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
... 117 }
... 118 for _, f := range []string{"-wait", "-provider"} {
... 119 if !strings.Contains(out.String(), f) {
... 120 t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String())
... 121 }
... 122 }
... 123 if strings.Contains(out.String(), "noch nicht implementiert") {
... 124 t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String())
... 125 }
... 126 }
108 127
109 128 func TestHelpSubcommand(t *testing.T) {
110 129 t.Run("ohne Argument", func(t *testing.T) {
cmd/talk/daemon.go --- 1/3 --- Go
24 24
25 // sayViaDaemon ist der Daemon-Pfad 25 // sayViaDaemon ist der Daemon-Pfad
.. von say: Erkennung über GET /status, .. von say: Erkennung über GET /status,
26 // dann enqueue (POST /speak). Defau 26 // dann enqueue (POST /speak). Defau
.. lt: sofort zurückkehren (non-blockin .. lt: sofort zurückkehren (non-blockin
.. g), .. g),
27 // stdout erhält die Item-ID; --wait 27 // info erhält die Item-ID; --wait p
.. pollt /status, bis die Ansage weder .. ollt /status, bis die Ansage weder
28 // läuft noch wartet. Bei aktivem Mu 28 // läuft noch wartet. Bei aktivem Mu
.. te verwirft der Daemon die Ansage — .. te verwirft der Daemon die Ansage —
29 // stdout sagt „muted". 29 // info sagt „muted". info ist der W
.. .. riter für Informations-Token (Item-I
.. .. D,
.. 30 // muted, discarded): say reicht std
.. .. out, wrap stderr durch — stdout eine
.. .. s
.. 31 // gewrappten Befehls darf nicht um
.. .. talk-Zeilen verunreinigt werden.
30 // 32 //
31 // handled=false heißt: kein Daemon 33 // handled=false heißt: kein Daemon
.. erreichbar → der Aufrufer geht in de .. erreichbar → der Aufrufer geht in de
.. n .. n
32 // Degraded Mode. Stirbt der Daemon 34 // Degraded Mode. Stirbt der Daemon
.. zwischen Erkennung und enqueue .. zwischen Erkennung und enqueue
33 // (Netz-Fehler), gilt dasselbe; ein 35 // (Netz-Fehler), gilt dasselbe; ein
.. e Daemon-Antwort mit Fehlerstatus .. e Daemon-Antwort mit Fehlerstatus
34 // (z. B. 400) wird dagegen angezeig 36 // (z. B. 400) wird dagegen angezeig
.. t — die Ansage war beim Daemon .. t — die Ansage war beim Daemon
35 // angekommen und wurde bewusst abge 37 // angekommen und wurde bewusst abge
.. lehnt. .. lehnt.
36 func sayViaDaemon(cfg config.Config, 38 func sayViaDaemon(cfg config.Config,
.. text string, opts *sayOptions, stdo .. text string, opts *sayOptions, info
.. ut, stderr io.Writer) (code int, han .. , stderr io.Writer) (code int, handl
.. dled bool) { .. ed bool) {
37 client := api.NewClient(cfg.Serv 39 client := api.NewClient(cfg.Serv
.. er.Port) .. er.Port)
38 40
39 probeCtx, cancelProbe := context 41 probeCtx, cancelProbe := context
.. .WithTimeout(context.Background(), d .. .WithTimeout(context.Background(), d
.. aemonProbeTimeout) .. aemonProbeTimeout)
cmd/talk/daemon.go --- 2/3 --- Go
61 return 0, false 63 return 0, false
62 } 64 }
63 if resp.Muted { 65 if resp.Muted {
64 fmt.Fprintln(stdout, "muted" 66 fmt.Fprintln(info, "muted")
.. ) ..
65 fmt.Fprintln(stderr, "talk: 67 fmt.Fprintln(stderr, "talk:
.. stumm geschaltet (mute) — Ansage ver .. stumm geschaltet (mute) — Ansage ver
.. worfen") .. worfen")
66 return exitOK, true 68 return exitOK, true
67 } 69 }
cmd/talk/daemon.go --- 3/3 --- Go
73 if resp.Toast { 75 if resp.Toast {
74 line += " (toast)" 76 line += " (toast)"
75 } 77 }
76 fmt.Fprintln(stdout, line) 78 fmt.Fprintln(info, line)
77 fmt.Fprintln(stderr, "talk: 79 fmt.Fprintln(stderr, "talk:
.. "+discardHint(resp.Discarded, resp.T .. "+discardHint(resp.Discarded, resp.T
.. oast)) .. oast))
78 return exitOK, true 80 return exitOK, true
79 } 81 }
80 fmt.Fprintln(stdout, resp.ID) 82 fmt.Fprintln(info, resp.ID)
81 if !opts.wait { 83 if !opts.wait {
82 return exitOK, true 84 return exitOK, true
83 } 85 }
cmd/talk/degraded.go --- Go
31 // dann Spracherkennung → Provider-K 31 // dann Spracherkennung → Provider-K
.. ette → Sink — identische Kette wie i .. ette → Sink — identische Kette wie i
.. m .. m
32 // Daemon, nur im CLI-Prozess (block 32 // Daemon, nur im CLI-Prozess (block
.. ierend; ohne Daemon ist --wait das .. ierend; ohne Daemon ist --wait das
33 // Default-Verhalten). Best-effort: 33 // Default-Verhalten). Best-effort:
.. Ohne Config-Dir läuft es ohne Lock u .. Ohne Config-Dir läuft es ohne Lock u
.. nd .. nd
34 // Mute weiter, statt zu scheitern. 34 // Mute weiter, statt zu scheitern.
.. .. info ist der Writer für Informations
.. .. -
.. 35 // Token („muted"): say reicht stdou
.. .. t, wrap stderr durch.
35 func sayDegraded(cfg config.Config, 36 func sayDegraded(cfg config.Config,
.. text string, opts *sayOptions, stdou .. text string, opts *sayOptions, info,
.. t, stderr io.Writer) int { .. stderr io.Writer) int {
36 if dir, err := config.Dir(getenv 37 if dir, err := config.Dir(getenv
.. ); err == nil { .. ); err == nil {
37 mute := queue.MuteStore{Path 38 mute := queue.MuteStore{Path
.. : filepath.Join(dir, queue.MuteFileN .. : filepath.Join(dir, queue.MuteFileN
.. ame)} .. ame)}
38 if mute.Muted() { 39 if mute.Muted() {
39 fmt.Fprintln(stdout, "mu 40 fmt.Fprintln(info, "mute
.. ted") .. d")
40 fmt.Fprintln(stderr, "ta 41 fmt.Fprintln(stderr, "ta
.. lk: stumm geschaltet (mute) — Ansage .. lk: stumm geschaltet (mute) — Ansage
.. verworfen") .. verworfen")
41 return exitOK 42 return exitOK
42 } 43 }
cmd/talk/main.go --- Go
1 // talk ist die CLI für Sprach-Ausga 1 // talk ist die CLI für Sprach-Ausga
. ben (talk 2.0). . ben (talk 2.0).
2 // 2 //
3 // Dieser Stand (Task 04): say (und 3 // Dieser Stand (Task 07): say (und
. der Default-Aufruf `talk "Text"`) re . der Default-Aufruf `talk "Text"`) re
. icht . icht
4 // Ansagen in den Daemon talkd ein — 4 // Ansagen in den Daemon talkd ein —
. non-blocking, --wait pollt bis zur . non-blocking, --wait pollt bis zur
5 // Fertigstellung, --now springt vor 5 // Fertigstellung, --now springt vor
. . Ohne Daemon fällt say in den Degra . . Ohne Daemon fällt say in den Degra
. ded . ded
6 // Mode (Lock-Datei, sequenziell lok 6 // Mode (Lock-Datei, sequenziell lok
. al, mute respektiert). mute/unmute/ . al, mute respektiert). mute/unmute/
7 // status/cancel sind Daemon-Aufrufe 7 // status/cancel sind Daemon-Aufrufe
. mit plain Output. Die übrigen Befeh . mit plain Output. `talk wrap -- <Be
. le . fehl>`
. 8 // führt Befehle aus (stdin/stdout/s
. . tderr durchgereicht, Exit-Code
. 9 // durchgereicht) und sagt danach Er
. . folg/Misserfolg und Dauer an. Die
8 // sind Stubs; TUI und weitere Aufga 10 // übrigen Befehle sind Stubs; TUI u
. ben folgen in den Tasks 05–09. .. nd weitere Aufgaben folgen in den
. 11 // Tasks 08–09.
9 package main 12 package main
10 13
11 import "os" 14 import "os"
cmd/talk/parse_test.go --- Go
1 package main
2
3 import (
4 "flag"
5 "io"
6 "strings"
7 "testing"
8 )
9
10 // TestParseInterspersedDoubleDash (Regression, Task 07): Das erste allein
11 // stehende „--" schirmt alles Folgenden von der Flag-Auswertung ab — der
12 // Kern der wrap-Aufrufform `talk wrap -- foo --bar`. Vorher nahm der
13 // Interspersed-Loop nach jedem Positionsargument das Parsen wieder auf und
14 // fraß damit Flags des nachfolgenden Befehls. Gleichzeitig bleibt die
15 // Abnahme-Form „Flag nach dem Text" (ohne „--") intakt.
16 func TestParseInterspersedDoubleDash(t *testing.T) {
17 newWrapFS := func() (*flag.FlagSet, *sayOptions) {
18 fs := flag.NewFlagSet("talk wrap", flag.ContinueOnError)
19 fs.SetOutput(io.Discard)
20 fs.Usage = func() {}
21 return fs, addSayFlags(fs)
22 }
23
24 t.Run("Doppelminus schirmt Befehls-Flags", func(t *testing.T) {
25 fs, opts := newWrapFS()
26 positional, err := parseInterspersed(fs, []string{"--", "foo", "--bar"})
27 if err != nil {
28 t.Fatalf("parse: %v", err)
29 }
30 if strings.Join(positional, " ") != "foo --bar" {
31 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
32 }
33 if opts.wait {
34 t.Error("--bar darf kein talk-Flag geworden sein (Shielding kaputt)")
35 }
36 })
37
38 t.Run("talk-Flags vor dem Doppelminus gelten", func(t *testing.T) {
39 fs, opts := newWrapFS()
40 positional, err := parseInterspersed(fs, []string{"--wait", "--", "sleep", "1"})
41 if err != nil {
42 t.Fatalf("parse: %v", err)
43 }
44 if !opts.wait {
45 t.Error("--wait vor dem -- muss als talk-Flag gelten")
46 }
47 if strings.Join(positional, " ") != "sleep 1" {
48 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"sleep", "1"})
49 }
50 })
51
52 t.Run("Positional vor dem Doppelminus wird mitgeschleppt", func(t *testing.T) {
53 fs, _ := newWrapFS()
54 positional, err := parseInterspersed(fs, []string{"foo", "--", "--bar"})
55 if err != nil {
56 t.Fatalf("parse: %v", err)
57 }
58 if strings.Join(positional, " ") != "foo --bar" {
59 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
60 }
61 })
62
63 t.Run("abgeschirmtes talk-Flag setzt den Flag nicht", func(t *testing.T) {
64 fs, opts := newWrapFS()
65 positional, err := parseInterspersed(fs, []string{"--", "--wait", "true"})
66 if err != nil {
67 t.Fatalf("parse: %v", err)
68 }
69 if opts.wait {
70 t.Error("--wait NACH dem -- gehört dem Befehl, nicht talk")
71 }
72 if strings.Join(positional, " ") != "--wait true" {
73 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"--wait", "true"})
74 }
75 })
76
77 t.Run("Flag nach dem Text ohne Doppelminus bleibt erlaubt", func(t *testing.T) {
78 fs, opts := newWrapFS()
79 positional, err := parseInterspersed(fs, []string{"Text", "--lang=en"})
80 if err != nil {
81 t.Fatalf("parse: %v (Abnahme-Form Flag-nach-Text kaputt)", err)
82 }
83 if strings.Join(positional, " ") != "Text" || opts.lang != "en" {
84 t.Errorf("Positionsargumente = %q, lang = %q — Flag-nach-Text muss weitergehen", positional, opts.lang)
85 }
86 })
87
88 t.Run("Text nach dem Doppelminus kommt vollständig durch (say)", func(t *testing.T) {
89 fs, _ := newWrapFS()
90 positional, err := parseInterspersed(fs, []string{"--", "Bericht", "mit --flag", "fertig"})
91 if err != nil {
92 t.Fatalf("parse: %v", err)
93 }
94 if strings.Join(positional, " ") != "Bericht mit --flag fertig" {
95 t.Errorf("Positionsargumente = %q, want Wörter unverändert", positional)
96 }
97 })
98 }
99
cmd/talk/say.go --- 1/2 --- Go
53 } 53 }
54 } 54 }
55 55
56 // Validierung vor jedem Daemon- 56 // Validierung vor jedem Daemon-
.. Kontakt: Benutzungsfehler sollen .. Kontakt (auch wrap erbt die say-Flag
.. .. s):
57 // schnell und ohne Netzwerk-Umw 57 // Benutzungsfehler sollen schne
.. ege auffallen. .. ll und ohne Netzwerk-Umwege auffalle
.. .. n.
58 if opts.lang != "" && opts.lang ..
.. != "de" && opts.lang != "en" { ..
59 fmt.Fprintf(stderr, "talk: - ..
.. -lang: ungültiger Wert %q (erlaubt: ..
.. de, en)\n", opts.lang) ..
60 return exitFailure ..
61 } ..
62 if opts.priority != "" && !queue 58 if code, bad := validateSayOptio
.. .Priority(opts.priority).Valid() { .. ns(opts, stderr); bad {
63 fmt.Fprintf(stderr, "talk: - ..
.. -priority: ungültiger Wert %q (erlau ..
.. bt: low, normal, high)\n", opts.prio ..
.. rity) ..
64 return exitFailure 59 return code
65 } 60 }
66 61
67 cfg, err := config.Load(config.O 62 cfg, err := config.Load(config.O
.. ptions{Getenv: getenv}) .. ptions{Getenv: getenv})
cmd/talk/say.go --- 2/2 --- Go
78 73 return sayDegraded(cfg, text, opts, stdout, stderr)
79 74 }
.. 75
.. 76 // validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
.. 77 // en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
.. 78 // Benutzungsfehler, bereits auf stderr gemeldet.
.. 79 func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
.. 80 if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
.. 81 fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
.. 82 return exitFailure, true
.. 83 }
.. 84 if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
.. 85 fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
.. 86 return exitFailure, true
.. 87 }
.. 88 return 0, false
.. 89 }
80 90
81 91 // pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
82 92 // `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
cmd/talk/wrap.go --- Go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "code.m3ta.dev/m3tam3re/talk/internal/config"
13 "code.m3ta.dev/m3tam3re/talk/internal/langdetect"
14 )
15
16 // Kind nicht gefunden — Shell-Konvention 127.
17 const exitNotFound = 127
18
19 // runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- <Befehl>
20 // [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden
21 // durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und
22 // sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
23 // „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
24 // Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
25 // Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue,
26 // non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt
27 // die say-Flags und reicht sie an die Ansage durch.
28 //
29 // Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
30 // Fehler ≠ Befehls-Fehler). Scheitert die Ansage, gibt es eine Warning auf
31 // stderr — der Befehls-Code bleibt. Benutzungsfehler von talk selbst
32 // (kein Befehl, unbekannte Flags, ungültiges --lang) fallen dagegen VOR
33 // dem Start des Befehls auf: Er läuft dann gar nicht erst. Nur
34 // Konfigurations-Fehler (kaputte config.toml/Env) treten nach dem Lauf
35 // auf — der Befehl bleibt gelaufen, die Ansage wird mit Warning ausgesetzt.
36 //
37 // Die Positionsargumente sind die Befehlszeile (Befehl + Argumente); das
38 // „--" selbst hat parseInterspersed bereits konsumiert — alles dahinter
39 // kam unverändert durch den Flag-Schutz hindurch.
40 func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
41 if len(args) == 0 {
42 fmt.Fprintln(stderr, "talk wrap: kein Befehl — Verwendung: talk wrap [Flags] -- <Befehl> [Argumente …]")
43 return exitUsage
44 }
45 if code, bad := validateSayOptions(opts, stderr); bad {
46 return code
47 }
48
49 start := time.Now()
50 runErr := execChild(args, stdin, stdout, stderr)
51 duration := time.Since(start)
52 code := childExitCode(runErr)
53 if runErr != nil {
54 // Z. B. Binary nicht gefunden oder Signal-Tod: Der Hinweis gehört
55 // auf stderr; die Ansage nennt denselben Code, mit dem wrap exitet.
56 fmt.Fprintf(stderr, "talk wrap: Befehl nicht sauber beendet: %v\n", runErr)
57 }
58
59 cfg, err := config.Load(config.Options{Getenv: getenv})
60 if err != nil {
61 fmt.Fprintf(stderr, "talk wrap: Ansage ausgesetzt — Config: %v (Exit-Code %d des Befehls bleibt)\n", err, code)
62 return code
63 }
64
65 label := wrapLabel(cfg.Wrap.Label, args[0])
66 lang := opts.lang
67 if lang == "" {
68 lang = langdetect.Detect(label)
69 }
70 announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
71 return code
72 }
73
74 // announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say:
75 // Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon
76 // Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
77 // Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
78 // gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
79 // Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
80 // auf stderr — stdout gehört allein dem (bereits beendeten) Befehl, damit
81 // Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
82 // werden.
83 func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
84 warn := func() {
85 fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
86 }
87 if code, handled := sayViaDaemon(cfg, text, opts, stderr, stderr); handled {
88 if code != 0 {
89 warn()
90 }
91 return
92 }
93 fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
94 if sayDegraded(cfg, text, opts, stderr, stderr) != exitOK {
95 warn()
96 }
97 }
98
99 // execChild startet argv als Kindprozess und wartet auf sein Ende.
100 // stdin/stdout/stderr werden unverändert durchgereicht — der Befehl
101 // besitzt die Konsole, talk mischt sich nicht ein (die Ansage selbst geht
102 // erst NACH dem Ende des Befehls raus). Reines os/exec ohne
103 // plattformspezifische Aufrufe: läuft unverändert auf Windows, macOS und
104 // Linux (Kindprozesse startet das jeweilige Betriebssystem-Exec selbst).
105 func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
106 cmd := exec.Command(argv[0], argv[1:]...)
107 cmd.Stdin = stdin
108 cmd.Stdout = stdout
109 cmd.Stderr = stderr
110 return cmd.Run()
111 }
112
113 // childExitCode übersetzt das Run-Ergebnis des Kindprozesses in den
114 // Exit-Code, mit dem wrap selbst exitet (Durchreich-Vertrag):
115 //
116 // - normales Ende: der Exit-Code des Befehls (0 = Erfolg).
117 // - Ende durch Signal: portabel auf 1 normiert. Die Unix-Konvention
118 // 128+Signalnummer bräuchte syscall.WaitStatus (Signaltode meldet Go
119 // als ExitCode -1) und wäre nicht mehr frei cross-compilierbar; die
120 // Ansage nennt bewusst denselben normierten Code, mit dem wrap exitet.
121 // - Start fehlgeschlagen (Binary nicht gefunden/nicht ausführbar):
122 // 127, die Shell-Konvention — der Befehl ist gescheitert, ohne je
123 // gelaufen zu sein.
124 func childExitCode(runErr error) int {
125 if runErr == nil {
126 return 0
127 }
128 var ee *exec.ExitError
129 if errors.As(runErr, &ee) {
130 if c := ee.ExitCode(); c >= 0 {
131 return c
132 }
133 return exitFailure
134 }
135 return exitNotFound
136 }
137
138 // wrapLabel löst das Ansage-Label: config `wrap.label` (Env-Override
139 // TALK_WRAP_LABEL) schlägt den Basisnamen des Befehls
140 // (`/usr/bin/make` → „make").
141 func wrapLabel(cfgLabel, command string) string {
142 if cfgLabel != "" {
143 return cfgLabel
144 }
145 return filepath.Base(command)
146 }
147
148 // formatDuration macht eine Wandzeit menschenlesbar, lokalisiert de/en:
149 // „3 Minuten 42 Sekunden" / „3 minutes 42 seconds". Gerundet auf ganze
150 // Sekunden (halb aufwärts); Nullteile fallen weg („1 Minute"), nur bei
151 // insgesamt null bleibt „0 Sekunden" stehen. Die Einzahl wird dekliniert
152 // („1 Sekunde", „1 minute"). Alles über einer Stunde nennt auch Stunden.
153 func formatDuration(d time.Duration, lang string) string {
154 type unit struct{ singular, plural string }
155 units := []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
156 if lang == "en" {
157 units = []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
158 }
159 secs := int64(d.Round(time.Second) / time.Second)
160 n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
161 var parts []string
162 for i, u := range units {
163 if n[i] == 0 {
164 continue
165 }
166 word := u.plural
167 if n[i] == 1 {
168 word = u.singular
169 }
170 parts = append(parts, fmt.Sprintf("%d %s", n[i], word))
171 }
172 if len(parts) == 0 {
173 return fmt.Sprintf("0 %s", units[2].plural)
174 }
175 return strings.Join(parts, " ")
176 }
177
178 // announcementText baut die wrap-Ansage nach Brief: Erfolg
179 // „<label> erfolgreich. Dauer: X." bzw. Misserfolg
180 // „<label> fehlgeschlagen, Code N." — lokalisiert (englisch:
181 // „<label> succeeded. Duration: X." / „<label> failed, code N."). Die
182 // Sprache bestimmt die Spracherkennung des Labels (oder --lang, falls
183 // gesetzt); alles außer "en" spricht deutsch (langdetect-Default).
184 func announcementText(label string, code int, d time.Duration, lang string) string {
185 if lang == "en" {
186 if code == 0 {
187 return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
188 }
189 return fmt.Sprintf("%s failed, code %d.", label, code)
190 }
191 if code == 0 {
192 return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
193 }
194 return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
195 }
196
cmd/talk/wrap_test.go --- Go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os/exec"
9 "regexp"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 // TestFormatDuration ist tischgesteuert für die menschliche Dauer-Format
16 // (de/en): Gerundung auf ganze Sekunden, Nullteile fallen weg, Einzahl
17 // wird dekliniert, Stunden ab einer Stunde, „0 Sekunden" als Fallback.
18 func TestFormatDuration(t *testing.T) {
19 cases := []struct {
20 d time.Duration
21 de string
22 en string
23 }{
24 {0, "0 Sekunden", "0 seconds"},
25 {300 * time.Millisecond, "0 Sekunden", "0 seconds"},
26 {500 * time.Millisecond, "1 Sekunde", "1 second"}, // halb aufwärts
27 {1500 * time.Millisecond, "2 Sekunden", "2 seconds"},
28 {1 * time.Second, "1 Sekunde", "1 second"},
29 {42 * time.Second, "42 Sekunden", "42 seconds"},
30 {61 * time.Second, "1 Minute 1 Sekunde", "1 minute 1 second"},
31 {222 * time.Second, "3 Minuten 42 Sekunden", "3 minutes 42 seconds"},
32 {3600 * time.Second, "1 Stunde", "1 hour"},
33 {3601 * time.Second, "1 Stunde 1 Sekunde", "1 hour 1 second"},
34 {3660 * time.Second, "1 Stunde 1 Minute", "1 hour 1 minute"},
35 {3720 * time.Second, "1 Stunde 2 Minuten", "1 hour 2 minutes"},
36 {90061 * time.Second, "25 Stunden 1 Minute 1 Sekunde", "25 hours 1 minute 1 second"},
37 }
38 for _, tc := range cases {
39 if got := formatDuration(tc.d, "de"); got != tc.de {
40 t.Errorf("formatDuration(%v, de) = %q, want %q", tc.d, got, tc.de)
41 }
42 if got := formatDuration(tc.d, "en"); got != tc.en {
43 t.Errorf("formatDuration(%v, en) = %q, want %q", tc.d, got, tc.en)
44 }
45 }
46 }
47
48 // TestAnnouncementText: die Ansage-Texte nach Brief, lokalisiert de/en —
49 // Erfolg mit Dauer, Misserfolg mit Code (ohne Dauer).
50 func TestAnnouncementText(t *testing.T) {
51 d := 222 * time.Second
52 cases := []struct {
53 lang string
54 code int
55 want string
56 }{
57 {"de", 0, "Backup erfolgreich. Dauer: 3 Minuten 42 Sekunden."},
58 {"de", 2, "Backup fehlgeschlagen, Code 2."},
59 {"en", 0, "Backup succeeded. Duration: 3 minutes 42 seconds."},
60 {"en", 2, "Backup failed, code 2."},
61 }
62 for _, tc := range cases {
63 if got := announcementText("Backup", tc.code, d, tc.lang); got != tc.want {
64 t.Errorf("announcementText(Backup, %d, %v, %s) = %q, want %q", tc.code, d, tc.lang, got, tc.want)
65 }
66 }
67 }
68
69 // TestWrapLabel: config-Label schlägt den Basisnamen des Befehls.
70 func TestWrapLabel(t *testing.T) {
71 if got := wrapLabel("", "/usr/bin/make"); got != "make" {
72 t.Errorf("wrapLabel(\"\", /usr/bin/make) = %q, want %q", got, "make")
73 }
74 if got := wrapLabel("Deployment", "/usr/bin/terraform"); got != "Deployment" {
75 t.Errorf("wrapLabel(Deployment, …) = %q, want %q", got, "Deployment")
76 }
77 if got := wrapLabel("", "./script.sh"); got != "script.sh" {
78 t.Errorf("wrapLabel(\"\", ./script.sh) = %q, want %q", got, "script.sh")
79 }
80 }
81
82 // TestWrapOhneBefehl: `talk wrap` ohne Befehl ist ein klarer
83 // Benutzungsfehler — der Befehl (nicht vorhanden) läuft natürlich nicht.
84 func TestWrapOhneBefehl(t *testing.T) {
85 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
86 var out, errBuf bytes.Buffer
87 code := runCLI([]string{"wrap"}, &out, &errBuf)
88 if code != exitUsage {
89 t.Fatalf("talk wrap ohne Befehl: Exit-Code = %d, want %d", code, exitUsage)
90 }
91 if !strings.Contains(errBuf.String(), "kein Befehl") {
92 t.Errorf("stderr = %q, will Hinweis auf fehlenden Befehl", errBuf.String())
93 }
94 }
95
96 // TestWrapValidiertSayFlagsVorDemStart: ungültige geerbte say-Flags
97 // scheitern VOR dem Befehlsstart (Benutzungsfehler laufen nie den Befehl).
98 func TestWrapValidiertSayFlagsVorDemStart(t *testing.T) {
99 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
100 var out, errBuf bytes.Buffer
101 code := runCLI([]string{"wrap", "--lang=fr", "--", "true"}, &out, &errBuf)
102 if code != exitFailure {
103 t.Fatalf("talk wrap --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
104 }
105 if !strings.Contains(errBuf.String(), "--lang") {
106 t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
107 }
108 }
109
110 // wrapFakeTTS ist ein ElevenLabs-Fake, der den ANSAGE-TEXT aufzeichnet
111 // (der CLI-Fake in say_test.go beobachtet nur Pfad/Key — wrap braucht den
112 // gesprochenen Text als Beobachtung).
113 func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
114 t.Helper()
115 texts = make(chan string, 8)
116 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 var body struct {
118 Text string `json:"text"`
119 }
120 _ = json.NewDecoder(r.Body).Decode(&body)
121 texts <- body.Text
122 w.WriteHeader(status)
123 _, _ = w.Write([]byte("\xff\xfb\x90\x00 fake-mp3"))
124 }))
125 t.Cleanup(ts.Close)
126 return ts.URL, texts
127 }
128
129 // wrapEnv ist die Degraded-Grundausstattung der In-Prozess-wrap-Tests:
130 // Daemon garantiert tot (TALK_PORT=1), Kette auf den Fake gepinnt — die
131 // Ansage läuft deterministisch lokal gegen den Fake (sink null). extra
132 // ergänzt weitere Env-Werte (ein Aufruf, kein Verdrängen der Basis).
133 func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
134 t.Helper()
135 kv := map[string]string{
136 "TALK_AUDIO_SINK": "null",
137 "TALK_PROVIDER": "elevenlabs",
138 "TALK_ELEVENLABS_KEY": "wrap-testkey",
139 "TALK_ELEVENLABS_ENDPOINT": endpoint,
140 }
141 for k, v := range extra {
142 kv[k] = v
143 }
144 setGetenv(t, kv)
145 }
146
147 // needUnixBin überspringt den Test, wenn das Binary fehlt (die
148 // In-Prozess-Tests starten echte Kinder: true/sh — auf dem Linux-Dev-Host
149 // und in CI immer vorhanden).
150 func needUnixBin(t *testing.T, name string) {
151 t.Helper()
152 if _, err := exec.LookPath(name); err != nil {
153 t.Skipf("%s fehlt: %v", name, err)
154 }
155 }
156
157 // TestWrapDurchreichungErfolg: `talk wrap -- true` (Daemon tot → Degraded,
158 // Ansage gegen den Fake) exitet 0 und sagt den Erfolg mit Dauer.
159 func TestWrapDurchreichungErfolg(t *testing.T) {
160 needUnixBin(t, "true")
161 url, texts := wrapFakeTTS(t, 200)
162 wrapEnv(t, url, nil)
163
164 var out, errBuf bytes.Buffer
165 code := runCLI([]string{"wrap", "--", "true"}, &out, &errBuf)
166 if code != exitOK {
167 t.Fatalf("talk wrap -- true: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
168 }
169 select {
170 case text := <-texts:
171 // true läuft Millisekunden — Dauer rund 0; unter Last auch gern
172 // 1 Sekunde (Einzahl). Nur Struktur verifiziert.
173 if !regexp.MustCompile(`^true erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(text) {
174 t.Errorf("Ansage = %q, will „true erfolgreich. Dauer: N Sekunde(n).“", text)
175 }
176 default:
177 t.Error("Ansage lief nicht (Fake nicht gerufen)")
178 }
179 }
180
181 // TestWrapDurchreichungFehlschlag: `talk wrap -- sh -c "exit 3"` exitet 3
182 // und sagt den Misserfolg mit genau diesem Code.
183 func TestWrapDurchreichungFehlschlag(t *testing.T) {
184 needUnixBin(t, "sh")
185 url, texts := wrapFakeTTS(t, 200)
186 wrapEnv(t, url, nil)
187
188 var out, errBuf bytes.Buffer
189 code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
190 if code != 3 {
191 t.Fatalf("talk wrap -- sh -c 'exit 3': Exit-Code = %d, want 3", code)
192 }
193 select {
194 case text := <-texts:
195 if text != "sh fehlgeschlagen, Code 3." {
196 t.Errorf("Ansage = %q, want %q", text, "sh fehlgeschlagen, Code 3.")
197 }
198 default:
199 t.Error("Ansage lief nicht (Fake nicht gerufen)")
200 }
201 }
202
203 // TestWrapStdoutGehoertBefehl: stdout der wrap-CLI führt NUR die Ausgabe
204 // des Befehls — die Informationszeilen der Ansage (hier: Degraded-Protokoll
205 // und Ketten-Logs) gehen auf stderr. Pipes des Befehls bleiben sauber.
206 func TestWrapStdoutGehoertBefehl(t *testing.T) {
207 needUnixBin(t, "sh")
208 url, _ := wrapFakeTTS(t, 200)
209 wrapEnv(t, url, nil)
210
211 var out, errBuf bytes.Buffer
212 code := runCLI([]string{"wrap", "--", "sh", "-c", "echo kinderausgabe"}, &out, &errBuf)
213 if code != exitOK {
214 t.Fatalf("talk wrap -- sh -c echo: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
215 }
216 if out.String() != "kinderausgabe\n" {
217 t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe %q", out.String(), "kinderausgabe\n")
218 }
219 }
220
221 // TestWrapAnsageScheitertCodeBleibt: Stirbt die Ansage (Endpunkt tot,
222 // Kette gepinnt → kein Fallback), bleibt der Exit-Code des Befehls
223 // trotzdem — nur eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
224 func TestWrapAnsageScheitertCodeBleibt(t *testing.T) {
225 needUnixBin(t, "sh")
226 wrapEnv(t, "http://127.0.0.1:1/", nil) // Endpunkt garantiert tot
227
228 var out, errBuf bytes.Buffer
229 code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 4"}, &out, &errBuf)
230 if code != 4 {
231 t.Fatalf("talk wrap (Ansage scheitert): Exit-Code = %d, want 4 (Befehl gewinnt)", code)
232 }
233 if !strings.Contains(errBuf.String(), "Ansage fehlgeschlagen") {
234 t.Errorf("stderr = %q, will Warning, dass die Ansage scheiterte", errBuf.String())
235 }
236 }
237
238 // TestWrapBefehlNichtGefunden: unbekannter Befehl → Shell-Konvention 127,
239 // Ansage nennt denselben Code.
240 func TestWrapBefehlNichtGefunden(t *testing.T) {
241 url, texts := wrapFakeTTS(t, 200)
242 wrapEnv(t, url, nil)
243
244 var out, errBuf bytes.Buffer
245 code := runCLI([]string{"wrap", "--", "diesen-befehl-gibt-es-nicht"}, &out, &errBuf)
246 if code != exitNotFound {
247 t.Fatalf("talk wrap -- <unbekannt>: Exit-Code = %d, want %d", code, exitNotFound)
248 }
249 if !strings.Contains(errBuf.String(), "nicht sauber beendet") {
250 t.Errorf("stderr = %q, will Hinweis auf den Startfehler", errBuf.String())
251 }
252 select {
253 case text := <-texts:
254 if text != "diesen-befehl-gibt-es-nicht fehlgeschlagen, Code 127." {
255 t.Errorf("Ansage = %q, want Code-127-Ansage", text)
256 }
257 default:
258 t.Error("Ansage lief nicht (Fake nicht gerufen)")
259 }
260 }
261
262 // TestWrapLabelAusEnv: TALK_WRAP_LABEL ersetzt den Basisnamen.
263 func TestWrapLabelAusEnv(t *testing.T) {
264 needUnixBin(t, "false")
265 url, texts := wrapFakeTTS(t, 200)
266 wrapEnv(t, url, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
267
268 var out, errBuf bytes.Buffer
269 code := runCLI([]string{"wrap", "--", "false"}, &out, &errBuf)
270 if code != 1 {
271 t.Fatalf("talk wrap -- false: Exit-Code = %d, want 1", code)
272 }
273 select {
274 case text := <-texts:
275 if text != "Deployment fehlgeschlagen, Code 1." {
276 t.Errorf("Ansage = %q, want %q", text, "Deployment fehlgeschlagen, Code 1.")
277 }
278 default:
279 t.Error("Ansage lief nicht (Fake nicht gerufen)")
280 }
281 }
282
internal/config/config.go --- 1/3 --- Go
61 61 // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
62 62 Voice map[string]map[string]string `toml:"voice"`
.. 63
.. 64 // Wrap steuert die Befehlsummwicklung `talk wrap` (Task 07).
.. 65 Wrap Wrap `toml:"wrap"`
63 66
64 67 // QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
65 68 // (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
internal/config/config.go --- 2/3 --- Go
96 99 AllowedOrigins []string `toml:"allowed_origins"`
97 100 }
.. 101
.. 102 // Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
.. 103 type Wrap struct {
.. 104 // Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
.. 105 // `label = "Deployment"` für „Deployment erfolgreich. Dauer: …"
.. 106 // statt „terraform erfolgreich. Dauer: …" (Env-Override
.. 107 // TALK_WRAP_LABEL schlägt die Datei).
.. 108 Label string `toml:"label"`
.. 109 }
98 110
99 111 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
100 112 type Queue struct {
internal/config/config.go --- 3/3 --- Go
198 210 cfg.Server.AllowedOrigins = origins
199 211 }
200 212 }
... 213 if v := getenv("TALK_WRAP_LABEL"); v != "" {
... 214 cfg.Wrap.Label = v
... 215 }
201 216 if v := getenv("TALK_RATE_LIMIT"); v != "" {
202 217 d, err := time.ParseDuration(v)
203 218 if err != nil || d < 0 {
internal/config/config_test.go --- Go
294 294
295 295 // TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
296 296 // (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
... 297 // TestLoadWrapLabel: das Ansage-Label von `talk wrap` (Task 07) —
... 298 // Defaults → TOML → Env nach dem üblichen Schichten-Muster.
... 299 func TestLoadWrapLabel(t *testing.T) {
... 300 writeTOML := func(t *testing.T, content string) string {
... 301 t.Helper()
... 302 path := filepath.Join(t.TempDir(), "config.toml")
... 303 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
... 304 t.Fatal(err)
... 305 }
... 306 return path
... 307 }
... 308
... 309 t.Run("Default leer (Basisname des Befehls)", func(t *testing.T) {
... 310 cfg, err := Load(Options{
... 311 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
... 312 Getenv: env(nil),
... 313 })
... 314 if err != nil {
... 315 t.Fatalf("Load: %v", err)
... 316 }
... 317 if cfg.Wrap.Label != "" {
... 318 t.Errorf("Wrap.Label = %q, want leer (Default)", cfg.Wrap.Label)
... 319 }
... 320 })
... 321
... 322 t.Run("TOML überschreibt Default", func(t *testing.T) {
... 323 cfg, err := Load(Options{
... 324 File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
... 325 Getenv: env(nil),
... 326 })
... 327 if err != nil {
... 328 t.Fatalf("Load: %v", err)
... 329 }
... 330 if cfg.Wrap.Label != "Deployment" {
... 331 t.Errorf("Wrap.Label = %q, want %q aus TOML", cfg.Wrap.Label, "Deployment")
... 332 }
... 333 })
... 334
... 335 t.Run("TALK_WRAP_LABEL schlägt Datei", func(t *testing.T) {
... 336 cfg, err := Load(Options{
... 337 File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
... 338 Getenv: env(map[string]string{"TALK_WRAP_LABEL": "Nightly Build"}),
... 339 })
... 340 if err != nil {
... 341 t.Fatalf("Load: %v", err)
... 342 }
... 343 if cfg.Wrap.Label != "Nightly Build" {
... 344 t.Errorf("Wrap.Label = %q, want Env-Override %q", cfg.Wrap.Label, "Nightly Build")
... 345 }
... 346 })
... 347 }
... 348
297 349 func TestLoadServerPort(t *testing.T) {
298 350 writeTOML := func(t *testing.T, content string) string {
test/wrap_test.go --- Go
1 // wrap-Prozessgrenzen-Tests (Task 07): talk läuft als echter Prozess,
2 // talkd als echter Daemon, Fake-Provider + sink null wie im Harness —
3 // beobachtet wird nur extern: Exit-Codes, Befehls-stdout, Ansage-Text am
4 // Fake. Die gewrappten Befehle sind echte Binaries (sleep/sh/true), die
5 // Dauern bewusst klein.
6 package test
7
8 import (
9 "regexp"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 // TestWrapErfolgMitDauer (Abnahmekriterium): `talk wrap -- sleep 1`
16 // annonciert Erfolg inklusive Dauer und exitet 0.
17 func TestWrapErfolgMitDauer(t *testing.T) {
18 root := t.TempDir()
19 fake := newFakeProvider(t, 0)
20 d := startDaemon(t, root, fake.srv.URL, nil)
21 env := daemonEnv(root, d.port, fake.srv.URL, nil)
22
23 res := runTalk(t, env, "wrap", "--", "sleep", "1")
24 if res.code != 0 {
25 t.Fatalf("talk wrap -- sleep 1: Exit %d, stderr: %s", res.code, res.stderr)
26 }
27 hits := fake.waitCompleted(t, 1)
28 // sleep 1 misst ~1 s (unter Last gern mehr) — Zahl offen, Form nicht.
29 if !regexp.MustCompile(`^sleep erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(hits[0].Text) {
30 t.Errorf("Ansage = %q, will „sleep erfolgreich. Dauer: N Sekunde(n).“", hits[0].Text)
31 }
32 waitStatusIdle(t, d, 3*time.Second)
33 }
34
35 // TestWrapFehlschlagCodeDurchgereicht (Abnahmekriterium): scheitert der
36 // Befehl mit Code 2, exitet wrap mit 2 und sagt den Misserfolg mit Code —
37 // ohne Dauer, wortgenau nach Brief.
38 func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
39 root := t.TempDir()
40 fake := newFakeProvider(t, 0)
41 d := startDaemon(t, root, fake.srv.URL, nil)
42 env := daemonEnv(root, d.port, fake.srv.URL, nil)
43
44 res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
45 if res.code != 2 {
46 t.Fatalf("talk wrap -- sh -c 'exit 2': Exit %d, want 2 (Durchreichung)", res.code)
47 }
48 hits := fake.waitCompleted(t, 1)
49 if hits[0].Text != "sh fehlgeschlagen, Code 2." {
50 t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
51 }
52 waitStatusIdle(t, d, 3*time.Second)
53 }
54
55 // TestWrapFlagShielding (Regression, Task-07-Kern): `talk wrap -- true
56 // --bar` reicht --bar an den Befehl weiter — talk darf --bar NICHT als
57 // eigenes Flag parsen (wäre Exit 2 mit Flag-Fehler). GNU true ignoriert
58 // Operanden, exitet also 0, und die Erfolg-Ansage läuft.
59 func TestWrapFlagShielding(t *testing.T) {
60 root := t.TempDir()
61 fake := newFakeProvider(t, 0)
62 d := startDaemon(t, root, fake.srv.URL, nil)
63 env := daemonEnv(root, d.port, fake.srv.URL, nil)
64
65 res := runTalk(t, env, "wrap", "--", "true", "--bar")
66 if res.code != 0 {
67 t.Fatalf("talk wrap -- true --bar: Exit %d, want 0 (--bar gehört dem Befehl) — stderr: %s", res.code, res.stderr)
68 }
69 if strings.Contains(res.stderr, "flag provided but not defined") {
70 t.Errorf("talk hat --bar als eigenes Flag geparsed — Shielding kaputt: %s", res.stderr)
71 }
72 hits := fake.waitCompleted(t, 1)
73 if !strings.HasPrefix(hits[0].Text, "true erfolgreich.") {
74 t.Errorf("Ansage = %q, will Erfolg für true", hits[0].Text)
75 }
76 waitStatusIdle(t, d, 3*time.Second)
77 }
78
79 // TestWrapLabelOverride: TALK_WRAP_LABEL ersetzt den Basisnamen in der
80 // Ansage (Fleet-Muster: Env statt Config-Datei).
81 func TestWrapLabelOverride(t *testing.T) {
82 root := t.TempDir()
83 fake := newFakeProvider(t, 0)
84 d := startDaemon(t, root, fake.srv.URL, nil)
85 env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
86
87 res := runTalk(t, env, "wrap", "--", "false")
88 if res.code != 1 {
89 t.Fatalf("talk wrap -- false: Exit %d, want 1", res.code)
90 }
91 hits := fake.waitCompleted(t, 1)
92 if hits[0].Text != "Deployment fehlgeschlagen, Code 1." {
93 t.Errorf("Ansage = %q, want %q", hits[0].Text, "Deployment fehlgeschlagen, Code 1.")
94 }
95 waitStatusIdle(t, d, 3*time.Second)
96 }
97
98 // TestWrapNonBlocking: wrap kehrt nach dem enqueue zurück, bevor die
99 // Ansage zu Ende gespielt ist (Ansage dauert 600 ms am Fake) — stdout
100 // gehört dabei allein dem Befehl, die Item-ID geht auf stderr.
101 func TestWrapNonBlocking(t *testing.T) {
102 root := t.TempDir()
103 fake := newFakeProvider(t, 600*time.Millisecond)
104 d := startDaemon(t, root, fake.srv.URL, nil)
105 env := daemonEnv(root, d.port, fake.srv.URL, nil)
106
107 start := time.Now()
108 res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo bauausgabe")
109 elapsed := time.Since(start)
110
111 if res.code != 0 {
112 t.Fatalf("talk wrap: Exit %d, stderr: %s", res.code, res.stderr)
113 }
114 if elapsed >= 500*time.Millisecond {
115 t.Fatalf("talk wrap war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
116 }
117 if res.stdout != "bauausgabe\n" {
118 t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe (Item-ID gehört auf stderr)", res.stdout)
119 }
120 if strings.TrimSpace(res.stderr) == "" {
121 t.Errorf("stderr = %q, will mindestens die Item-ID der Ansage", res.stderr)
122 }
123
124 // Die Ansage läuft im Daemon trotzdem zu Ende.
125 fake.waitCompleted(t, 1)
126 waitStatusIdle(t, d, 3*time.Second)
127 }
128
129 // TestWrapWait: `talk wrap --wait -- …` kehrt erst nach Ende der Ansage
130 // zurück (geerbtes say-Flag, hier: 400 ms Synthese).
131 func TestWrapWait(t *testing.T) {
132 root := t.TempDir()
133 fake := newFakeProvider(t, 400*time.Millisecond)
134 d := startDaemon(t, root, fake.srv.URL, nil)
135 env := daemonEnv(root, d.port, fake.srv.URL, nil)
136
137 start := time.Now()
138 res := runTalk(t, env, "wrap", "--wait", "--", "true")
139 elapsed := time.Since(start)
140
141 if res.code != 0 {
142 t.Fatalf("talk wrap --wait: Exit %d, stderr: %s", res.code, res.stderr)
143 }
144 if elapsed < 350*time.Millisecond {
145 t.Fatalf("talk wrap --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
146 }
147 waitStatusIdle(t, d, 3*time.Second)
148 }
149
150 // TestWrapDegradedMode: Daemon tot → Ansage im Degraded Mode (Lock-Datei,
151 // direkt gegen den Fake), und der Exit-Code des Befehls bleibt trotzdem.
152 func TestWrapDegradedMode(t *testing.T) {
153 root := t.TempDir()
154 fake := newFakeProvider(t, 0)
155 env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
156
157 res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
158 if res.code != 3 {
159 t.Fatalf("talk wrap (Degraded): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
160 }
161 hits := fake.waitCompleted(t, 1)
162 if hits[0].Text != "sh fehlgeschlagen, Code 3." {
163 t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
164 }
165 if !strings.Contains(res.stderr, "Degraded Mode") {
166 t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
167 }
168 }
169