1287 lines
61 KiB
Diff
1287 lines
61 KiB
Diff
# 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
|
||
|