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

1287 lines
61 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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