3815 lines
162 KiB
Diff
3815 lines
162 KiB
Diff
# Review package: 3b44605..HEAD
|
||
|
||
## Commits
|
||
dc94f45 docs(readme): Daemon talkd, HTTP-Endpunkte, Mute-Persistenz und Degraded Mode dokumentieren
|
||
1ae1f12 test: Prozessgrenzen-Tests — talkd + talk als echte Prozesse gegen Fake-Provider und sink null
|
||
e1a3981 feat(cli): say über den Daemon, Steuer-Befehle als Daemon-Aufrufe, Degraded Mode
|
||
d09aff6 feat(talkd): Daemon mit HTTP-API auf 127.0.0.1, Queue-Betrieb und Shutdown
|
||
39c3563 feat(api): HTTP-Vertrag des Daemons — Endpunkte, Validierung und CLI-Client
|
||
e492bad feat(queue): serialisierte Ansage-Queue mit Prioritäten, now-Sprung, Mute-Store und Rate-Limit-Struktur
|
||
716f6b2 feat(config): Daemon-Port server.port mit Env-Override TALK_PORT (Default 7800)
|
||
|
||
## Files changed
|
||
README.md | 41 +++-
|
||
cmd/talk/cli.go | 56 +++---
|
||
cmd/talk/cli_test.go | 7 +-
|
||
cmd/talk/control.go | 126 ++++++++++++
|
||
cmd/talk/control_test.go | 118 +++++++++++
|
||
cmd/talk/daemon.go | 93 +++++++++
|
||
cmd/talk/degraded.go | 116 +++++++++++
|
||
cmd/talk/main.go | 10 +-
|
||
cmd/talk/say.go | 71 +++----
|
||
cmd/talk/say_test.go | 11 +-
|
||
cmd/talkd/main.go | 249 ++++++++++++++++++++++-
|
||
internal/api/client.go | 123 ++++++++++++
|
||
internal/api/doc.go | 8 +-
|
||
internal/api/server.go | 173 ++++++++++++++++
|
||
internal/api/types.go | 81 ++++++++
|
||
internal/config/config.go | 33 ++-
|
||
internal/config/config_test.go | 64 ++++++
|
||
internal/queue/doc.go | 7 +-
|
||
internal/queue/mute.go | 84 ++++++++
|
||
internal/queue/queue.go | 288 ++++++++++++++++++++++++++
|
||
internal/queue/queue_test.go | 309 ++++++++++++++++++++++++++++
|
||
internal/queue/rate.go | 57 ++++++
|
||
test/cli_test.go | 323 +++++++++++++++++++++++++++++
|
||
test/harness_test.go | 447 +++++++++++++++++++++++++++++++++++++++++
|
||
test/mute_test.go | 103 ++++++++++
|
||
test/queue_test.go | 273 +++++++++++++++++++++++++
|
||
26 files changed, 3169 insertions(+), 102 deletions(-)
|
||
|
||
## Diff
|
||
README.md --- Text
|
||
28 Say-Flags (gelten auch für `talk "Te 28 Say-Flags (gelten auch für `talk "Te
|
||
.. xt"`): `--wait`, `--now`, `--priorit .. xt"`): `--wait`, `--now`, `--priorit
|
||
.. y`, .. y`,
|
||
29 `--voice`, `--lang`, `--provider`. 29 `--voice`, `--lang`, `--provider`.
|
||
30 30
|
||
31 **Hinweis:** In diesem Stand ist `sa 31 **Hinweis:** In diesem Stand sind im
|
||
.. y` (und der Default-Aufruf .. plementiert: `say` (und `talk "Text"
|
||
.. .. `)
|
||
32 `talk "Text"`) implementiert — inklu 32 mit der kompletten Provider-Kette un
|
||
.. sive der kompletten Provider-Kette .. d Daemon-Anbindung (non-blocking übe
|
||
.. .. r
|
||
33 (ElevenLabs → OpenAI → Edge-TTS → OS ..
|
||
.. -Stimme, blockierend; ..
|
||
34 Daemon-Anbindung folgt). Alle andere 33 `talkd`, siehe unten), `mute`, `unmu
|
||
.. n Befehle sind Stubs und melden .. te`, `status`, `cancel`. Die übrigen
|
||
35 `not implemented yet` (Exit-Code ≠ 0 34 Befehle sind Stubs und melden `not i
|
||
.. ). .. mplemented yet` (Exit-Code ≠ 0).
|
||
.. 35
|
||
.. 36 ## Daemon talkd (Task 04)
|
||
.. 37
|
||
.. 38 `talkd` besitzt die Ansage-Queue und
|
||
.. .. spricht seriell ab — genau eine Ans
|
||
.. .. age
|
||
.. 39 gleichzeitig, Reihenfolge `now` > `h
|
||
.. .. igh` > `normal` > `low` (FIFO je
|
||
.. 40 Level). Der HTTP-Server lauscht nur
|
||
.. .. auf `127.0.0.1`; Port: Default `7800
|
||
.. .. `,
|
||
.. 41 config `[server] port`, Env `TALK_PO
|
||
.. .. RT` (schlagen den Default).
|
||
.. 42
|
||
.. 43 | Endpunkt | Wirkung |
|
||
.. 44 | --- | --- |
|
||
.. 45 | `POST /speak` | `{text, priority?,
|
||
.. .. provider?, voice?, lang?, now?}` →
|
||
.. .. `202 {"id":"…"}`; bei Mute: `202 {"i
|
||
.. .. d":"","muted":true}` (Ansage verworf
|
||
.. .. en — dokumentiertes Verhalten) |
|
||
.. 46 | `GET /status` | `{"version", "queu
|
||
.. .. e", "queue_ids", "current", "muted",
|
||
.. .. "provider"}` |
|
||
.. 47 | `POST /cancel` | bricht die laufen
|
||
.. .. de Ansage ab + leert die Queue → `20
|
||
.. .. 0 {"cancelled":n}` |
|
||
.. 48 | `POST /mute` / `POST /unmute` | pe
|
||
.. .. rsistenter Mute-State → `200 {"muted
|
||
.. .. ":bool}` |
|
||
.. 49
|
||
.. 50 - **CLI-Verhalten:** `talk say` (und
|
||
.. .. `talk "Text"`) erkennt den Daemon v
|
||
.. .. ia
|
||
.. 51 `GET /status`. Läuft er: enqueue +
|
||
.. .. sofortige Rückkehr (non-blocking,
|
||
.. 52 stdout erhält die Item-ID); `--wai
|
||
.. .. t` pollt bis zur Fertigstellung;
|
||
.. 53 `--now` springt vor die Warteschla
|
||
.. .. nge; bei Mute sagt die CLI `muted`.
|
||
.. 54 `talk mute`/`unmute`/`status`/`can
|
||
.. .. cel` sind Daemon-Aufrufe mit plain
|
||
.. 55 Output.
|
||
.. 56 - **Mute ist persistent** (Datei `mu
|
||
.. .. te` im Config-Dir, Inhalt `true`/
|
||
.. 57 `false`) und überlebt Daemon-Resta
|
||
.. .. rts. `talk mute` ohne laufenden Daem
|
||
.. .. on
|
||
.. 58 schreibt die Datei direkt — der Da
|
||
.. .. emon liest sie bei jeder Ansage neu,
|
||
.. 59 der Zustand greift also auch offli
|
||
.. .. ne.
|
||
.. 60 - **Degraded Mode:** Antwortet kein
|
||
.. .. Daemon, spielt `talk` lokal und
|
||
.. 61 sequenziell weiter — serialisiert
|
||
.. .. über die Lock-Datei `talk.lock` im
|
||
.. 62 Config-Dir (Kollision = warten; ve
|
||
.. .. rwaisste Locks gelten nach 60 s als
|
||
.. 63 verfallen). Mute wird auch hier re
|
||
.. .. spektiert. Nichts bricht still.
|
||
.. 64 - Rate-Limit und Ruhezeiten folgen i
|
||
.. .. n Task 05; die Rate-Limit-Struktur
|
||
.. 65 existiert bereits (`internal/queue
|
||
.. .. `), ist in diesem Stand aber bewusst
|
||
.. 66 noch nicht angebunden (Default: un
|
||
.. .. begrenzt).
|
||
36 67
|
||
37 ### Provider-Kette (Task 03) 68 ### Provider-Kette (Task 03)
|
||
38 69
|
||
|
||
cmd/talk/cli.go --- 1/7 --- Go
|
||
26 args string // Positionsargumente-Hinweis für die Usage-Zeile
|
||
27 detail string // Beschreibung für `talk <Befehl> --help`
|
||
28 register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
|
||
29 handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
|
||
30 }
|
||
31
|
||
32 // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
||
|
||
cmd/talk/cli.go --- 2/7 --- Go
|
||
49 { 49 {
|
||
50 name: "mute", 50 name: "mute",
|
||
51 short: "Ansagen dauerhaft 51 short: "Ansagen dauerhaft
|
||
.. stummschalten", .. stummschalten",
|
||
52 detail: "Aktiviert den pers 52 detail: "Aktiviert den per
|
||
.. istenten Mute-State des Daemons (Da .. sistenten Mute-State (Datei `mute`
|
||
.. tei im Config-Verzeichnis).", .. im Config-Verzeichnis). Läuft der D
|
||
.. .. aemon, wird er darüber informiert;
|
||
.. .. andernfalls schreibt talk die Datei
|
||
.. .. direkt — der Zustand überlebt Daem
|
||
.. .. on-Restarts.",
|
||
.. 53 handler: runMute,
|
||
53 }, 54 },
|
||
54 { 55 {
|
||
55 name: "unmute", 56 name: "unmute",
|
||
56 short: "Stummschaltung auf 57 short: "Stummschaltung au
|
||
.. heben", .. fheben",
|
||
57 detail: "Hebt den persisten 58 detail: "Hebt den persiste
|
||
.. ten Mute-State des Daemons wieder a .. nten Mute-State wieder auf (Daemon-
|
||
.. uf.", .. Aufruf; ohne Daemon direkt über die
|
||
.. .. Datei).",
|
||
.. 59 handler: runUnmute,
|
||
58 }, 60 },
|
||
59 { 61 {
|
||
60 name: "status", 62 name: "status",
|
||
61 short: "Daemon-Status anze 63 short: "Daemon-Status anz
|
||
.. igen", .. eigen",
|
||
62 detail: "Zeigt Queue, aktue 64 detail: "Zeigt Queue, aktu
|
||
.. lles Item, Mute-State, aktiven Prov .. elles Item, Mute-State, aktiven Pro
|
||
.. ider und Daemon-Version.", .. vider und Daemon-Version (plain, ei
|
||
.. .. ne Zeile pro Feld).",
|
||
.. 65 handler: runStatus,
|
||
63 }, 66 },
|
||
64 { 67 {
|
||
65 name: "cancel", 68 name: "cancel",
|
||
66 short: "Laufende und gepla 69 short: "Laufende und gepl
|
||
.. nte Ansagen abbrechen", .. ante Ansagen abbrechen",
|
||
67 detail: "Bricht die laufend 70 detail: "Bricht die laufen
|
||
.. e Ansage ab und leert die Queue des .. de Ansage ab und leert die Queue de
|
||
.. Daemons.", .. s Daemons; Ausgabe ist die Zahl der
|
||
.. .. abgebrochenen Ansagen.",
|
||
.. 71 handler: runCancel,
|
||
68 }, 72 },
|
||
69 { 73 {
|
||
70 name: "voices", 74 name: "voices",
|
||
|
||
cmd/talk/cli.go --- 3/7 --- Go
|
||
90 94
|
||
91 // addSayFlags registriert die Flag 95 // addSayFlags registriert die Flag
|
||
.. s des say-Befehls und bindet sie an .. s des say-Befehls und bindet sie an
|
||
.. die .. die
|
||
92 // zurückgegebene Options-Struct. S 96 // zurückgegebene Options-Struct. S
|
||
.. ie gelten auch für den Default-Pfad .. ie gelten auch für den Default-Pfad
|
||
93 // `talk "Text"` (gleiche Flag-Ober 97 // `talk "Text"` (gleiche Flag-Ober
|
||
.. fläche, festgelegt in der Spec). Da .. fläche, festgelegt in der Spec). Ve
|
||
.. s .. rhalten
|
||
94 // Verhalten: direkter Pfad in Task 98 // seit Task 04: läuft ein Daemon,
|
||
.. 02, Daemon-Pfad in Task 04. .. wird enqueue (non-blocking); --wait
|
||
.. .. pollt
|
||
.. 99 // bis zur Fertigstellung, --now sp
|
||
.. .. ringt vor. Ohne Daemon: Degraded Mo
|
||
.. .. de.
|
||
95 // 100 //
|
||
96 // Rückgabe ist bewusst ein Pointer 101 // Rückgabe ist bewusst ein Pointer
|
||
.. : flag.Var bindet an die Felder gen ... : flag.Var bindet an die Felder gen
|
||
.. au ... au
|
||
97 // dieser Struct — eine Wertkopie h 102 // dieser Struct — eine Wertkopie h
|
||
.. ätte nach dem Parse weiterhin die ... ätte nach dem Parse weiterhin die
|
||
|
||
cmd/talk/cli.go --- 4/7 --- Go
|
||
120 125 writeUsage(stderr)
|
||
121 126 return exitUsage
|
||
122 127 }
|
||
123 128 return runSay(nil, &sayOptions{}, stdin, stdout, stderr)
|
||
124 129 }
|
||
125 130
|
||
126 131 if argv[0] == "help" {
|
||
|
||
cmd/talk/cli.go --- 5/7 --- Go
|
||
193 return fs, help, sayOpts 198 return fs, help, sayOpts
|
||
194 } 199 }
|
||
195 200
|
||
196 // runCommand führt einen benannten 201 // runCommand führt einen benannten
|
||
... Subcommand aus. say ist implementi ... Subcommand aus. Implementiert: say
|
||
... ert ...
|
||
... 202 // (Task 02/04) sowie mute/unmute/s
|
||
... ... tatus/cancel als Daemon-Aufrufe (Ta
|
||
... ... sk
|
||
197 // (Task 02); alle anderen sind Stu 203 // 04); die restlichen sind Stubs b
|
||
... bs bis zu ihren Tasks. Flags dürfen ... is zu ihren Tasks. Flags dürfen auc
|
||
... auch ... h
|
||
198 // nach dem Text stehen (parseInter 204 // nach dem Text stehen (parseInter
|
||
... spersed). ... spersed).
|
||
199 func runCommand(cmd command, args [ 205 func runCommand(cmd command, args [
|
||
... ]string, stdin io.Reader, stdout, s ... ]string, stdin io.Reader, stdout, s
|
||
... tderr io.Writer) int { ... tderr io.Writer) int {
|
||
200 fs, help, sayOpts := newCommand 206 fs, help, sayOpts := newCommand
|
||
... FlagSet(cmd, stderr) ... FlagSet(cmd, stderr)
|
||
|
||
cmd/talk/cli.go --- 6/7 --- Go
|
||
213 219 return exitOK
|
||
214 220 }
|
||
215 221 if cmd.handler != nil {
|
||
216 222 return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
|
||
217 223 }
|
||
218 224 return notImplemented(cmd.name, stderr)
|
||
219 225 }
|
||
|
||
cmd/talk/cli.go --- 7/7 --- Go
|
||
245 251 writeUsage(stdout)
|
||
246 252 return exitOK
|
||
247 253 }
|
||
248 254 return runSay(positional, sayOpts, stdin, stdout, stderr)
|
||
249 255 }
|
||
250 256
|
||
251 257 // versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||
|
||
cmd/talk/cli_test.go --- Go
|
||
14 "say", "wrap", "mute", "unmute", 14 "say", "wrap", "mute", "unmute",
|
||
.. "status", "cancel", "voices", "test .. "status", "cancel", "voices", "test
|
||
.. ", "doctor", "setup", .. ", "doctor", "setup",
|
||
15 } 15 }
|
||
16 16
|
||
17 // wantStubCommands ist wantSubcomma 17 // wantStubCommands ist wantSubcomma
|
||
.. nds ohne say — say ist seit Task 02 .. nds ohne die implementierten Befehle
|
||
.. .. —
|
||
.. 18 // say (Task 02/04) sowie mute/unmut
|
||
.. .. e/status/cancel (Task 04, Daemon-
|
||
18 // implementiert, alle anderen bleib 19 // Aufrufe) sind real; die restliche
|
||
.. en Stubs bis zu ihren Tasks. .. n bleiben Stubs bis zu ihren Tasks.
|
||
19 var wantStubCommands = []string{ 20 var wantStubCommands = []string{
|
||
20 "wrap", "mute", "unmute", "statu 21 "wrap", "voices", "test", "docto
|
||
.. s", "cancel", "voices", "test", "doc .. r", "setup",
|
||
.. tor", "setup", ..
|
||
21 } 22 }
|
||
22 23
|
||
23 // runCLI ist der Test-Einstieg in r 24 // runCLI ist der Test-Einstieg in r
|
||
.. un mit leerem stdin (kein Text über .. un mit leerem stdin (kein Text über
|
||
|
||
cmd/talk/control.go --- Go
|
||
1 package main
|
||
2
|
||
3 import (
|
||
4 "context"
|
||
5 "fmt"
|
||
6 "io"
|
||
7 "path/filepath"
|
||
8
|
||
9 "code.m3ta.dev/m3tam3re/talk/internal/api"
|
||
10 "code.m3ta.dev/m3tam3re/talk/internal/config"
|
||
11 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||
12 )
|
||
13
|
||
14 // Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output:
|
||
15 // stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N
|
||
16 // bzw. den Status-Block), stderr menschliche Hinweise.
|
||
17
|
||
18 // runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
|
||
19 // Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
|
||
20 // Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
|
||
21 // Zustand greift also auch offline und überlebt Daemon-Restarts.
|
||
22 func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||
23 return setMute(true, stdout, stderr)
|
||
24 }
|
||
25
|
||
26 // runUnmute implementiert `talk unmute` (analog zu runMute).
|
||
27 func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||
28 return setMute(false, stdout, stderr)
|
||
29 }
|
||
30
|
||
31 // setMute ist der gemeinsame Kern von mute/unmute.
|
||
32 func setMute(muted bool, stdout, stderr io.Writer) int {
|
||
33 cfg, err := config.Load(config.Options{Getenv: getenv})
|
||
34 if err != nil {
|
||
35 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
36 return exitFailure
|
||
37 }
|
||
38 client := api.NewClient(cfg.Server.Port)
|
||
39 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||
40 defer cancel()
|
||
41 if err := client.SetMute(ctx, muted); err == nil {
|
||
42 printMuteState(muted, stdout)
|
||
43 return exitOK
|
||
44 }
|
||
45
|
||
46 // Daemon-Aufruf fehlgeschlagen (meist: nicht erreichbar): Datei direkt
|
||
47 // schreiben — der Daemon (und der Degraded Mode) liest sie bei jeder
|
||
48 // Ansage neu.
|
||
49 fmt.Fprintf(stderr, "talk: Daemon-Aufruf fehlgeschlagen (%v) — Mute-Datei wird direkt geschrieben\n", err)
|
||
50 dir, err := config.Dir(getenv)
|
||
51 if err != nil {
|
||
52 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
53 return exitFailure
|
||
54 }
|
||
55 if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil {
|
||
56 fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err)
|
||
57 return exitFailure
|
||
58 }
|
||
59 printMuteState(muted, stdout)
|
||
60 return exitOK
|
||
61 }
|
||
62
|
||
63 // printMuteState schreibt den Zustand als plain Token auf stdout.
|
||
64 func printMuteState(muted bool, stdout io.Writer) {
|
||
65 if muted {
|
||
66 fmt.Fprintln(stdout, "muted")
|
||
67 } else {
|
||
68 fmt.Fprintln(stdout, "unmuted")
|
||
69 }
|
||
70 }
|
||
71
|
||
72 // runStatus implementiert `talk status`: plain Block aus GET /status —
|
||
73 // Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
|
||
74 // aktiver Provider. Ohne Daemon: klarer Fehler (status ist eine Anfrage an
|
||
75 // den Daemon; der Degraded Mode hat keinen Status).
|
||
76 func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||
77 cfg, err := config.Load(config.Options{Getenv: getenv})
|
||
78 if err != nil {
|
||
79 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
80 return exitFailure
|
||
81 }
|
||
82 client := api.NewClient(cfg.Server.Port)
|
||
83 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||
84 defer cancel()
|
||
85 st, err := client.Status(ctx)
|
||
86 if err != nil {
|
||
87 fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL)
|
||
88 return exitFailure
|
||
89 }
|
||
90
|
||
91 fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
|
||
92 fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
|
||
93 if st.Current != nil {
|
||
94 fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
|
||
95 } else {
|
||
96 fmt.Fprintln(stdout, "current: –")
|
||
97 }
|
||
98 muteState := "aus"
|
||
99 if st.Muted {
|
||
100 muteState = "an"
|
||
101 }
|
||
102 fmt.Fprintf(stdout, "mute: %s\n", muteState)
|
||
103 fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
|
||
104 return exitOK
|
||
105 }
|
||
106
|
||
107 // runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
|
||
108 // Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
|
||
109 // Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
|
||
110 func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||
111 cfg, err := config.Load(config.Options{Getenv: getenv})
|
||
112 if err != nil {
|
||
113 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
114 return exitFailure
|
||
115 }
|
||
116 client := api.NewClient(cfg.Server.Port)
|
||
117 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||
118 defer cancel()
|
||
119 n, err := client.Cancel(ctx)
|
||
120 if err != nil {
|
||
121 fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL)
|
||
122 return exitFailure
|
||
123 }
|
||
124 fmt.Fprintf(stdout, "cancelled: %d\n", n)
|
||
125 return exitOK
|
||
126 }
|
||
127
|
||
|
||
cmd/talk/control_test.go --- Go
|
||
1 package main
|
||
2
|
||
3 import (
|
||
4 "bytes"
|
||
5 "os"
|
||
6 "path/filepath"
|
||
7 "strings"
|
||
8 "testing"
|
||
9
|
||
10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||
11 )
|
||
12
|
||
13 // Diese Tests decken die Steuer-Befehle ohne Daemon ab (in-process über
|
||
14 // die run-Seam; TALK_PORT=1 aus der setGetenv-Grundausstattung → Daemon-
|
||
15 // Erkennung schlägt sofort fehl). Das Verhalten MIT Daemon testen die
|
||
16 // Prozessgrenzen-Tests in /test.
|
||
17
|
||
18 // TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
|
||
19 // mit Hinweis (kein Stillversagen).
|
||
20 func TestStatusOhneDaemon(t *testing.T) {
|
||
21 setGetenv(t, nil)
|
||
22 var out, errBuf bytes.Buffer
|
||
23 code := runCLI([]string{"status"}, &out, &errBuf)
|
||
24 if code != exitFailure {
|
||
25 t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||
26 }
|
||
27 if !strings.Contains(errBuf.String(), "nicht erreichbar") {
|
||
28 t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
|
||
29 }
|
||
30 if out.Len() != 0 {
|
||
31 t.Errorf("stdout = %q, will leer", out.String())
|
||
32 }
|
||
33 }
|
||
34
|
||
35 // TestCancelOhneDaemon: ohne Daemon gibt es nichts zu abbrechen — klarer
|
||
36 // Fehler.
|
||
37 func TestCancelOhneDaemon(t *testing.T) {
|
||
38 setGetenv(t, nil)
|
||
39 var out, errBuf bytes.Buffer
|
||
40 code := runCLI([]string{"cancel"}, &out, &errBuf)
|
||
41 if code != exitFailure {
|
||
42 t.Fatalf("talk cancel ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||
43 }
|
||
44 if !strings.Contains(errBuf.String(), "nichts zu abbrechen") {
|
||
45 t.Errorf("stderr = %q, will Hinweis", errBuf.String())
|
||
46 }
|
||
47 }
|
||
48
|
||
49 // TestMuteOhneDaemonSchreibtDatei: `talk mute` ohne Daemon schreibt die
|
||
50 // Mute-Datei direkt — und genau diese Datei respektiert der Degraded Mode
|
||
51 // von say anschließend.
|
||
52 func TestMuteOhneDaemonSchreibtDatei(t *testing.T) {
|
||
53 root := t.TempDir()
|
||
54 setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root})
|
||
55 mutePath := filepath.Join(root, "talk", queue.MuteFileName)
|
||
56
|
||
57 var out, errBuf bytes.Buffer
|
||
58 if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK {
|
||
59 t.Fatalf("talk mute ohne Daemon: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||
60 }
|
||
61 if got := out.String(); got != "muted\n" {
|
||
62 t.Errorf("talk mute stdout = %q, want %q", got, "muted\n")
|
||
63 }
|
||
64 if !strings.Contains(errBuf.String(), "direkt") {
|
||
65 t.Errorf("stderr = %q, will Hinweis auf direkten Datei-Schreib", errBuf.String())
|
||
66 }
|
||
67 b, err := os.ReadFile(mutePath)
|
||
68 if err != nil {
|
||
69 t.Fatalf("Mute-Datei fehlt: %v", err)
|
||
70 }
|
||
71 if strings.TrimSpace(string(b)) != "true" {
|
||
72 t.Errorf("Mute-Datei = %q, want true", b)
|
||
73 }
|
||
74
|
||
75 // say im Degraded Mode respektiert dieselbe Datei (tonlos verworfen,
|
||
76 // ohne irgendeinen Provider zu brauchen).
|
||
77 out.Reset()
|
||
78 errBuf.Reset()
|
||
79 if code := runCLI([]string{"say", "stille bitte"}, &out, &errBuf); code != exitOK {
|
||
80 t.Fatalf("talk say (mute an): Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||
81 }
|
||
82 if got := out.String(); got != "muted\n" {
|
||
83 t.Errorf("talk say (mute an) stdout = %q, want %q", got, "muted\n")
|
||
84 }
|
||
85
|
||
86 // unmute hebt es auf (Datei false).
|
||
87 out.Reset()
|
||
88 errBuf.Reset()
|
||
89 if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK {
|
||
90 t.Fatalf("talk unmute: Exit-Code = %d (stderr: %q)", code, errBuf.String())
|
||
91 }
|
||
92 if got := out.String(); got != "unmuted\n" {
|
||
93 t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n")
|
||
94 }
|
||
95 b, err = os.ReadFile(mutePath)
|
||
96 if err != nil || strings.TrimSpace(string(b)) != "false" {
|
||
97 t.Errorf("Mute-Datei nach unmute = %q (%v), want false", b, err)
|
||
98 }
|
||
99 }
|
||
100
|
||
101 // TestSayOhneConfigDir: Degraded Mode ohne Config-Dir (kein HOME) spielt
|
||
102 // weiter statt zu scheitern — Best-effort ohne Lock und Mute.
|
||
103 func TestSayOhneConfigDir(t *testing.T) {
|
||
104 seen := withFakeEspeak(t)
|
||
105 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||
106
|
||
107 var out, errBuf bytes.Buffer
|
||
108 code := runCLI([]string{"Hallo"}, &out, &errBuf)
|
||
109 if code != exitOK {
|
||
110 t.Fatalf("talk ohne HOME: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||
111 }
|
||
112 if _, err := os.Stat(seen); err != nil {
|
||
113 t.Errorf("fake espeak nicht gerufen: %v", err)
|
||
114 }
|
||
115 if !strings.Contains(errBuf.String(), "Degraded Mode") {
|
||
116 t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
|
||
117 }
|
||
118 }
|
||
119
|
||
|
||
cmd/talk/daemon.go --- Go
|
||
1 package main
|
||
2
|
||
3 import (
|
||
4 "context"
|
||
5 "errors"
|
||
6 "fmt"
|
||
7 "io"
|
||
8 "time"
|
||
9
|
||
10 "code.m3ta.dev/m3tam3re/talk/internal/api"
|
||
11 "code.m3ta.dev/m3tam3re/talk/internal/config"
|
||
12 )
|
||
13
|
||
14 // Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
|
||
15 // hält kurz: Auf einem freien Port wird die Verbindung sofort abgelehnt;
|
||
16 // das Budget deckt nur unglückliche Fälle (gefilterte Ports), damit talk
|
||
17 // ohne Daemon nicht spürbar trödelt.
|
||
18 const (
|
||
19 daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
|
||
20 daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
|
||
21 waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
|
||
22 )
|
||
23
|
||
24 // sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
|
||
25 // dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
|
||
26 // stdout erhält die Item-ID; --wait pollt /status, bis die Ansage weder
|
||
27 // läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
|
||
28 // stdout sagt „muted".
|
||
29 //
|
||
30 // handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
|
||
31 // Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
|
||
32 // (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
|
||
33 // (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
|
||
34 // angekommen und wurde bewusst abgelehnt.
|
||
35 func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) (code int, handled bool) {
|
||
36 client := api.NewClient(cfg.Server.Port)
|
||
37
|
||
38 probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
|
||
39 defer cancelProbe()
|
||
40 if _, err := client.Status(probeCtx); err != nil {
|
||
41 return 0, false
|
||
42 }
|
||
43
|
||
44 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||
45 defer cancel()
|
||
46 resp, err := client.Speak(ctx, api.SpeakRequest{
|
||
47 Text: text,
|
||
48 Priority: opts.priority,
|
||
49 Provider: opts.provider,
|
||
50 Voice: opts.voice,
|
||
51 Lang: opts.lang,
|
||
52 Now: opts.now,
|
||
53 })
|
||
54 if err != nil {
|
||
55 var httpErr *api.HTTPError
|
||
56 if errors.As(err, &httpErr) {
|
||
57 fmt.Fprintf(stderr, "talk: Daemon hat die Ansage abgelehnt: %v\n", err)
|
||
58 return exitFailure, true
|
||
59 }
|
||
60 return 0, false
|
||
61 }
|
||
62 if resp.Muted {
|
||
63 fmt.Fprintln(stdout, "muted")
|
||
64 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
||
65 return exitOK, true
|
||
66 }
|
||
67 fmt.Fprintln(stdout, resp.ID)
|
||
68 if !opts.wait {
|
||
69 return exitOK, true
|
||
70 }
|
||
71 return waitForItem(client, resp.ID, stderr), true
|
||
72 }
|
||
73
|
||
74 // waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
|
||
75 // sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
|
||
76 // nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das
|
||
77 // ein Fehler: Der Zustand der Ansage ist unbekannt, und still warten wäre
|
||
78 // das Gegenteil von --wait.
|
||
79 func waitForItem(client *api.Client, id string, stderr io.Writer) int {
|
||
80 for {
|
||
81 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||
82 st, err := client.Status(ctx)
|
||
83 cancel()
|
||
84 if err != nil {
|
||
85 fmt.Fprintf(stderr, "talk: Daemon beim Warten nicht mehr erreichbar (%v)\n", err)
|
||
86 return exitFailure
|
||
87 }
|
||
88 if !st.PendingOrCurrent(id) {
|
||
89 return exitOK
|
||
90 }
|
||
91 time.Sleep(waitPollInterval)
|
||
92 }
|
||
93 }
|
||
94
|
||
|
||
cmd/talk/degraded.go --- Go
|
||
1 package main
|
||
2
|
||
3 import (
|
||
4 "context"
|
||
5 "errors"
|
||
6 "fmt"
|
||
7 "io"
|
||
8 "os"
|
||
9 "path/filepath"
|
||
10 "time"
|
||
11
|
||
12 "code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||
13 "code.m3ta.dev/m3tam3re/talk/internal/config"
|
||
14 "code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||
15 "code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||
16 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||
17 )
|
||
18
|
||
19 // Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen;
|
||
20 // nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir
|
||
21 // serialisiert konkurrierende talk-Prozesse; Kollision heißt warten.
|
||
22 const (
|
||
23 lockFileName = "talk.lock"
|
||
24 lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais
|
||
25 lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten
|
||
26 lockPollInterval = 50 * time.Millisecond
|
||
27 )
|
||
28
|
||
29 // sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe
|
||
30 // Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren,
|
||
31 // dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im
|
||
32 // Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das
|
||
33 // Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und
|
||
34 // Mute weiter, statt zu scheitern.
|
||
35 func sayDegraded(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) int {
|
||
36 if dir, err := config.Dir(getenv); err == nil {
|
||
37 mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
|
||
38 if mute.Muted() {
|
||
39 fmt.Fprintln(stdout, "muted")
|
||
40 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
||
41 return exitOK
|
||
42 }
|
||
43 release, err := acquireLock(filepath.Join(dir, lockFileName))
|
||
44 if err != nil {
|
||
45 fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err)
|
||
46 } else {
|
||
47 defer release()
|
||
48 }
|
||
49 }
|
||
50
|
||
51 lang := opts.lang
|
||
52 if lang == "" {
|
||
53 lang = langdetect.Detect(text)
|
||
54 }
|
||
55
|
||
56 sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
||
57 if err != nil {
|
||
58 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
59 return exitFailure
|
||
60 }
|
||
61
|
||
62 chain, err := provider.NewChain(provider.ChainConfig{
|
||
63 Pin: pickProvider(opts.provider, cfg.Provider),
|
||
64 Voice: opts.voice,
|
||
65 Voices: cfg.Voice,
|
||
66 Endpoints: endpointsOf(cfg),
|
||
67 Model: cfg.Providers["openai"].Model,
|
||
68 Getenv: getenv,
|
||
69 Player: sink,
|
||
70 Log: stderr,
|
||
71 })
|
||
72 if err != nil {
|
||
73 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
74 return exitFailure
|
||
75 }
|
||
76
|
||
77 if err := chain.Speak(context.Background(), text, lang); err != nil {
|
||
78 fmt.Fprintf(stderr, "talk: %v\n", err)
|
||
79 return exitFailure
|
||
80 }
|
||
81 return exitOK
|
||
82 }
|
||
83
|
||
84 // acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine
|
||
85 // stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt
|
||
86 // (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach
|
||
87 // lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion
|
||
88 // (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt —
|
||
89 // Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe
|
||
90 // nur bis zur Stale-Frist.
|
||
91 func acquireLock(path string) (release func(), err error) {
|
||
92 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
93 return nil, err
|
||
94 }
|
||
95 deadline := time.Now().Add(lockWaitTimeout)
|
||
96 for {
|
||
97 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||
98 if err == nil {
|
||
99 return func() {
|
||
100 f.Close()
|
||
101 _ = os.Remove(path)
|
||
102 }, nil
|
||
103 }
|
||
104 if !errors.Is(err, os.ErrExist) {
|
||
105 return nil, err
|
||
106 }
|
||
107 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
|
||
108 _ = os.Remove(path)
|
||
109 continue
|
||
110 }
|
||
111 if !time.Now().Before(deadline) {
|
||
112 return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path)
|
||
113 }
|
||
114 time.Sleep(lockPollInterval)
|
||
115 }
|
||
116 }
|
||
117
|
||
|
||
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 02) liefert de 3 // Dieser Stand (Task 04): say (und
|
||
. n say-Pfad end-to-end: Text aus . der Default-Aufruf `talk "Text"`) re
|
||
. . icht
|
||
4 // Argument oder stdin → Spracherken 4 // Ansagen in den Daemon talkd ein —
|
||
. nung → Provider (osbuiltin) → . non-blocking, --wait pollt bis zur
|
||
5 // Wiedergabe über einen Audio-Sink 5 // Fertigstellung, --now springt vor
|
||
. (oto/null). Die übrigen Befehle sind . . Ohne Daemon fällt say in den Degra
|
||
. . ded
|
||
. 6 // Mode (Lock-Datei, sequenziell lok
|
||
. . al, mute respektiert). mute/unmute/
|
||
. 7 // status/cancel sind Daemon-Aufrufe
|
||
. . mit plain Output. Die übrigen Befeh
|
||
. . le
|
||
6 // Stubs; Daemon, Kette und TUI folg 8 // sind Stubs; TUI und weitere Aufga
|
||
. en in den Tasks 03–09. . ben folgen in den Tasks 05–09.
|
||
7 package main 9 package main
|
||
8 10
|
||
9 import "os" 11 import "os"
|
||
|
||
cmd/talk/say.go --- 1/3 --- Go
|
||
1 package main 1 package main
|
||
2 2
|
||
3 import ( 3 import (
|
||
4 "context" .
|
||
5 "fmt" 4 "fmt"
|
||
6 "io" 5 "io"
|
||
7 "os" 6 "os"
|
||
8 "strings" 7 "strings"
|
||
9 8
|
||
10 "code.m3ta.dev/m3tam3re/talk/in .
|
||
.. ternal/audio" .
|
||
11 "code.m3ta.dev/m3tam3re/talk/in 9 "code.m3ta.dev/m3tam3re/talk/in
|
||
.. ternal/config" . ternal/config"
|
||
12 "code.m3ta.dev/m3tam3re/talk/in .
|
||
.. ternal/langdetect" .
|
||
13 "code.m3ta.dev/m3tam3re/talk/in 10 "code.m3ta.dev/m3tam3re/talk/in
|
||
.. ternal/provider" .. ternal/queue"
|
||
14 ) 11 )
|
||
15 12
|
||
16 // getenv ist die Env-Seam von run 13 // getenv ist die Env-Seam von run
|
||
.. (Tests injizieren hier TALK_AUDIO_S .. (Tests injizieren hier TALK_AUDIO_S
|
||
.. INK .. INK,
|
||
17 // etc., ohne den Prozess-Environme 14 // TALK_PORT etc., ohne den Prozess
|
||
.. nt anzufassen). Default: os.Getenv. .. -Environment anzufassen). Default:
|
||
.. 15 // os.Getenv.
|
||
18 var getenv = os.Getenv 16 var getenv = os.Getenv
|
||
19 17
|
||
20 // sayOptions bündelt die Flags von 18 // sayOptions bündelt die Flags von
|
||
.. say (identisch im Default-Pfad .. say (identisch im Default-Pfad
|
||
|
||
cmd/talk/say.go --- 2/3 --- Go
|
||
28 provider string 26 provider string
|
||
29 } 27 }
|
||
30 28
|
||
31 // runSay ist der komplette Ansage- 29 // runSay ist der say-Eingangspfad:
|
||
.. Pfad (direkt und blockierend; .. Text aus Argumenten oder stdin sam
|
||
.. .. meln
|
||
32 // Daemon-Anbindung folgt in Task 0 30 // und validieren, dann Daemon-Anbi
|
||
.. 4): Text aus Argumenten oder stdin .. ndung (Task 04): Läuft ein talkd
|
||
.. → ..
|
||
33 // Spracherkennung → Provider-Kette 31 // (erkannt via GET /status), wird
|
||
.. (Task 03: elevenlabs → openai → ed .. die Ansage dort eingereiht — non-bl
|
||
.. ge → .. ocking
|
||
34 // osbuiltin; --provider/config pro 32 // als Default, --wait pollt bis zu
|
||
.. vider pinnen) → Synthese mit Fallba .. r Fertigstellung, --now springt vor
|
||
.. ck → .. ,
|
||
35 // Sink.Play. Auf Plattformen, dere 33 // Priorität/Provider/Voice/Lang we
|
||
.. n OS-TTS keine Bytes liefern kann ( .. rden durchgereicht. Antwortet kein
|
||
.. Linux ..
|
||
36 // ohne espeak), springt die dokume 34 // Daemon, fällt talk in den Degrad
|
||
.. ntierte Erweiterung provider.Direct .. ed Mode (Lock-Datei, sequenziell lo
|
||
.. Speaker .. kal,
|
||
37 // an (spd-say); mit audio.sink=nul 35 // mute respektiert — sayDegraded).
|
||
.. l wird die Ansage stattdessen verwo .. Die Provider-Kette selbst ist in b
|
||
.. rfen — .. eiden
|
||
38 // CI/Server bleiben ton- und fehle 36 // Pfarden dieselbe (internal/provi
|
||
.. rfrei. .. der, unverändert).
|
||
39 func runSay(args []string, opts *sa 37 func runSay(args []string, opts *sa
|
||
.. yOptions, stdin io.Reader, stderr i .. yOptions, stdin io.Reader, stdout,
|
||
.. o.Writer) int { .. stderr io.Writer) int {
|
||
40 text := strings.Join(args, " ") 38 text := strings.Join(args, " ")
|
||
41 if text == "" { 39 if text == "" {
|
||
42 if isCharDevice(stdin) { 40 if isCharDevice(stdin) {
|
||
|
||
cmd/talk/say.go --- 3/3 --- Go
|
||
55 } 53 }
|
||
56 } 54 }
|
||
57 55
|
||
.. 56 // Validierung vor jedem Daemon
|
||
.. .. -Kontakt: Benutzungsfehler sollen
|
||
.. 57 // schnell und ohne Netzwerk-Um
|
||
.. .. wege auffallen.
|
||
58 lang := opts.lang 58 if opts.lang != "" && opts.lang
|
||
.. .. != "de" && opts.lang != "en" {
|
||
59 if lang == "" { ..
|
||
60 lang = langdetect.Detect(te ..
|
||
.. xt) ..
|
||
61 } ..
|
||
62 if lang != "de" && lang != "en" ..
|
||
.. { ..
|
||
63 fmt.Fprintf(stderr, "talk: 59 fmt.Fprintf(stderr, "talk:
|
||
.. --lang: ungültiger Wert %q (erlaubt .. --lang: ungültiger Wert %q (erlaubt
|
||
.. : de, en)\n", lang) .. : de, en)\n", opts.lang)
|
||
64 return exitFailure 60 return exitFailure
|
||
65 } 61 }
|
||
66 ..
|
||
67 cfg, err := config.Load(config. ..
|
||
.. Options{Getenv: getenv}) ..
|
||
68 if err != nil { 62 if opts.priority != "" && !queu
|
||
.. .. e.Priority(opts.priority).Valid() {
|
||
69 fmt.Fprintf(stderr, "talk: ..
|
||
.. %v\n", err) ..
|
||
70 return exitFailure ..
|
||
71 } ..
|
||
72 ..
|
||
73 // Sink zuerst: Die Kette brauc ..
|
||
.. ht ihn als Player (Bytes-Wiedergabe ..
|
||
.. ) und ..
|
||
74 // für die null-Entscheidung be ..
|
||
.. i der Direktansage. ..
|
||
75 sink, err := audio.NewSink(cfg. ..
|
||
.. Audio.Sink, stderr) ..
|
||
76 if err != nil { ..
|
||
77 fmt.Fprintf(stderr, "talk: 63 fmt.Fprintf(stderr, "talk:
|
||
.. %v\n", err) .. --priority: ungültiger Wert %q (erl
|
||
.. .. aubt: low, normal, high)\n", opts.p
|
||
.. .. riority)
|
||
78 return exitFailure 64 return exitFailure
|
||
79 } 65 }
|
||
80 66
|
||
81 chain, err := provider.NewChain 67 cfg, err := config.Load(config.
|
||
.. (provider.ChainConfig{ .. Options{Getenv: getenv})
|
||
82 Pin: pickProvider(opt ..
|
||
.. s.provider, cfg.Provider), ..
|
||
83 Voice: opts.voice, ..
|
||
84 Voices: cfg.Voice, ..
|
||
85 Endpoints: endpointsOf(cfg) ..
|
||
.. , ..
|
||
86 Model: cfg.Providers["o ..
|
||
.. penai"].Model, ..
|
||
87 Getenv: getenv, ..
|
||
88 Player: sink, ..
|
||
89 Log: stderr, ..
|
||
90 }) ..
|
||
91 if err != nil { 68 if err != nil {
|
||
92 fmt.Fprintf(stderr, "talk: 69 fmt.Fprintf(stderr, "talk:
|
||
.. %v\n", err) .. %v\n", err)
|
||
93 return exitFailure 70 return exitFailure
|
||
94 } 71 }
|
||
95 72
|
||
96 ctx := context.Background() ..
|
||
97 if err := chain.Speak(ctx, text 73 if code, handled := sayViaDaemo
|
||
.. , lang); err != nil { .. n(cfg, text, opts, stdout, stderr);
|
||
.. .. handled {
|
||
98 fmt.Fprintf(stderr, "talk: ..
|
||
.. %v\n", err) ..
|
||
99 return exitFailure 74 return code
|
||
100 } 75 }
|
||
... 76
|
||
... 77 fmt.Fprintln(stderr, "talk: Dae
|
||
... .. mon nicht erreichbar — Degraded Mod
|
||
... .. e (lokal, sequenziell über Lock-Dat
|
||
... .. ei)")
|
||
101 return exitOK 78 return sayDegraded(cfg, text, o
|
||
... .. pts, stdout, stderr)
|
||
102 } 79 }
|
||
103 80
|
||
104 // pickProvider löst den Provider-P 81 // pickProvider löst den Provider-P
|
||
... in auf: CLI-Flag --provider schlägt .. in auf: CLI-Flag --provider schlägt
|
||
|
||
cmd/talk/say_test.go --- Go
|
||
14 // stattung jedes Tests: TALK_EDGE_E 14 // stattung jedes Tests: TALK_EDGE_E
|
||
.. NDPOINT zeigt auf einen garantiert .. NDPOINT zeigt auf einen garantiert
|
||
15 // toten Loopback-Port (Verbindung w 15 // toten Loopback-Port (Verbindung w
|
||
.. ird sofort abgelehnt) — die Default- .. ird sofort abgelehnt) — die Default-
|
||
16 // Kette enthält edge, und kein Test 16 // Kette enthält edge, und kein Test
|
||
.. darf je den echten Microsoft-Endpun .. darf je den echten Microsoft-Endpun
|
||
.. kt .. kt
|
||
.. 17 // anfassen. Dasselbe gilt seit Task
|
||
.. .. 04 für TALK_PORT: Die Daemon-Erkenn
|
||
.. .. ung
|
||
.. 18 // von say zeigt auf einen toten Por
|
||
.. .. t, damit die Tests hier Deterministi
|
||
.. .. sch
|
||
.. 19 // den Degraded Mode laufen (und nie
|
||
.. .. einen zufällig laufenden echten tal
|
||
.. .. kd
|
||
17 // anfassen. Wer edge bewusst anders 20 // treffen). Wer einen dieser Ports
|
||
.. braucht, überschreibt den Key. .. bewusst anders braucht, überschreibt
|
||
.. 21 // den Key.
|
||
18 func setGetenv(t *testing.T, kv map[ 22 func setGetenv(t *testing.T, kv map[
|
||
.. string]string) { .. string]string) {
|
||
19 t.Helper() 23 t.Helper()
|
||
20 base := map[string]string{"TALK_ 24 base := map[string]string{
|
||
.. EDGE_ENDPOINT": "ws://127.0.0.1:1/"} ..
|
||
.. 25 "TALK_EDGE_ENDPOINT": "ws://
|
||
.. .. 127.0.0.1:1/",
|
||
.. 26 "TALK_PORT": "1",
|
||
.. 27 }
|
||
21 for k, v := range kv { 28 for k, v := range kv {
|
||
22 base[k] = v 29 base[k] = v
|
||
|
||
cmd/talkd/main.go --- Go
|
||
1 // talkd ist der Daemon für talk 2. 1 // talkd ist der Daemon für talk 2.
|
||
. 0 (Queue, Mute-State, HTTP-API, MCP . 0: HTTP-API auf 127.0.0.1 (Default-
|
||
. ). . Port
|
||
. 2 // 7800, server.port/TALK_PORT), se
|
||
. . rialisierte Ansage-Queue mit Priori
|
||
. . täten
|
||
. 3 // und now-Sprung, persistenter Mut
|
||
. . e-State (Datei mute im Config-Dir).
|
||
2 // 4 //
|
||
3 // Dieser Stand (Task 01) ist ein P 5 // Task 04: POST /speak, GET /statu
|
||
. latzhalter: talkd startet, loggt ei . s, POST /cancel, POST /mute, POST /
|
||
. ne Zeile . unmute.
|
||
4 // und beendet sich mit Exit-Code 0 6 // Rate-Limit/Ruhezeiten folgen in
|
||
. . Die eigentliche Daemon-Implementi . Task 05, CORS/MCP in Task 06 — die
|
||
. erung .
|
||
5 // (HTTP-Server auf 127.0.0.1, seri 7 // Rate-Limit-Struktur existiert be
|
||
. alisierte Ansage-Queue, persistente . reits (internal/queue/rate.go), ist
|
||
. s Mute) . hier
|
||
6 // entsteht in Task 04. 8 // aber bewusst noch nicht angebund
|
||
. . en (Task-04-Default: unbegrenzt).
|
||
7 package main 9 package main
|
||
8 10
|
||
9 import "log" 11 import (
|
||
. 12 "context"
|
||
. 13 "fmt"
|
||
. 14 "log"
|
||
. 15 "net"
|
||
. 16 "net/http"
|
||
. 17 "os"
|
||
. 18 "os/signal"
|
||
. 19 "path/filepath"
|
||
. 20 "strings"
|
||
. 21 "sync"
|
||
. 22 "syscall"
|
||
. 23 "time"
|
||
. 24
|
||
. 25 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/api"
|
||
. 26 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/audio"
|
||
. 27 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/config"
|
||
. 28 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/langdetect"
|
||
. 29 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/provider"
|
||
. 30 "code.m3ta.dev/m3tam3re/talk/in
|
||
. .. ternal/queue"
|
||
. 31 )
|
||
. 32
|
||
. 33 // Build-Informationen. Werden beim
|
||
. .. Release per -ldflags injiziert (gl
|
||
. .. eicher
|
||
. 34 // Vertrag wie cmd/talk, siehe .gor
|
||
. .. eleaser.yaml in Task 11). Default:
|
||
. .. dev.
|
||
. 35 var (
|
||
. 36 version = "dev"
|
||
. 37 commit = ""
|
||
. 38 )
|
||
10 39
|
||
11 func main() { 40 func main() {
|
||
12 log.Println("talkd: Platzhalter 41 log.SetFlags(log.LstdFlags)
|
||
.. — Daemon-Implementierung folgt in ..
|
||
.. Task 04") ..
|
||
.. 42 log.SetPrefix("talkd: ")
|
||
.. 43 if err := run(); err != nil {
|
||
.. 44 log.Fatal(err)
|
||
.. 45 }
|
||
13 } 46 }
|
||
47
|
||
48 // run startet den Daemon und kehrt
|
||
.. .. erst beim Herunterfahren (SIGINT/
|
||
49 // SIGTERM) zurück. Abbrüche der la
|
||
.. .. ufenden Ansage beim Shutdown sind
|
||
50 // beabsichtigt: talkd soll auf Sig
|
||
.. .. nale prompt reagieren.
|
||
51 func run() error {
|
||
52 getenv := os.Getenv
|
||
53
|
||
54 cfg, err := config.Load(config.
|
||
.. .. Options{Getenv: getenv})
|
||
55 if err != nil {
|
||
56 return err
|
||
57 }
|
||
58
|
||
59 // Audio-Sink (audio.sink; "nul
|
||
.. .. l" verwirft tonlos — CI/Server).
|
||
60 // Ketten-, Queue- und Sink-Pro
|
||
.. .. tokoll laufen über denselben Logger
|
||
.. .. .
|
||
61 lw := lineLogger{}
|
||
62 sink, err := audio.NewSink(cfg.
|
||
.. .. Audio.Sink, lw)
|
||
63 if err != nil {
|
||
64 return err
|
||
65 }
|
||
66
|
||
67 // Basiskette nur für die /stat
|
||
.. .. us-Auflösung „aktiver Provider" (er
|
||
.. .. ste
|
||
68 // verfügbare Stufe; Available
|
||
.. .. ist billig — Key-/Endpunkt-Binary-C
|
||
.. .. hecks
|
||
69 // ohne Netz-Call). Das eigentl
|
||
.. .. iche Abspielen baut pro Item eine e
|
||
.. .. igene
|
||
70 // Kette (Pin/Voice je Anfrage)
|
||
.. .. — die Kette aus internal/provider
|
||
.. .. wird
|
||
71 // unverändert wiederverwendet.
|
||
72 baseChain, err := provider.NewC
|
||
.. .. hain(chainConfigFor(cfg, "", "", ge
|
||
.. .. tenv, nil))
|
||
73 if err != nil {
|
||
74 return err
|
||
75 }
|
||
76
|
||
77 state := &providerState{}
|
||
78 speak := func(ctx context.Conte
|
||
.. .. xt, item queue.Item) error {
|
||
79 state.set(resolveProviderNa
|
||
.. .. me(baseChain, item))
|
||
80 lang := item.Lang
|
||
81 if lang == "" {
|
||
82 lang = langdetect.Detec
|
||
.. .. t(item.Text)
|
||
83 }
|
||
84 chain, err := provider.NewC
|
||
.. .. hain(chainConfigFor(cfg, item.Provi
|
||
.. .. der, item.Voice, getenv, sink))
|
||
85 if err != nil {
|
||
86 return err
|
||
87 }
|
||
88 return chain.Speak(ctx, ite
|
||
.. .. m.Text, lang)
|
||
89 }
|
||
90
|
||
91 q := queue.New(speak, lw)
|
||
92
|
||
93 // Persistenter Mute-State (Dat
|
||
.. .. ei mute im Config-Dir). Ohne Config
|
||
.. .. -Dir
|
||
94 // läuft der Daemon ohne Mute w
|
||
.. .. eiter (dokumentierte Warnung) statt
|
||
.. .. zu
|
||
95 // scheitern — alles andere an
|
||
.. .. talk darf ja auch ohne Config-Datei
|
||
.. .. .
|
||
96 var mutePath string
|
||
97 if dir, err := config.Dir(geten
|
||
.. .. v); err == nil {
|
||
98 mutePath = filepath.Join(di
|
||
.. .. r, queue.MuteFileName)
|
||
99 } else {
|
||
100 log.Printf("warnung: Config
|
||
.. ... -Dir nicht bestimmbar — Mute-State
|
||
.. ... deaktiviert (%v)", err)
|
||
101 }
|
||
102 mute := queue.MuteStore{Path: m
|
||
.. ... utePath}
|
||
103
|
||
104 handler := api.NewHandler(api.H
|
||
.. ... andlerConfig{
|
||
105 Queue: q,
|
||
106 Mute: mute,
|
||
107 Version: versionStri
|
||
.. ... ng(),
|
||
108 KnownProviders: provider.Na
|
||
.. ... mes(),
|
||
109 Provider: func() string {
|
||
110 if cur, ok := q.Current
|
||
.. ... (); ok {
|
||
111 return resolveProvi
|
||
.. ... derName(baseChain, cur)
|
||
112 }
|
||
113 if last := state.get();
|
||
.. ... last != "" {
|
||
114 return last
|
||
115 }
|
||
116 return resolveProviderN
|
||
.. ... ame(baseChain, queue.Item{})
|
||
117 },
|
||
118 })
|
||
119
|
||
120 addr := fmt.Sprintf("127.0.0.1:
|
||
.. ... %d", cfg.Server.Port)
|
||
121 ln, err := net.Listen("tcp", ad
|
||
.. ... dr)
|
||
122 if err != nil {
|
||
123 return fmt.Errorf("Port bel
|
||
.. ... egen (%s): %w — läuft bereits ein t
|
||
.. ... alkd?", addr, err)
|
||
124 }
|
||
125 srv := &http.Server{Handler: ha
|
||
.. ... ndler, ReadHeaderTimeout: 5 * time.
|
||
.. ... Second}
|
||
126 errCh := make(chan error, 1)
|
||
127 go func() { errCh <- srv.Serve(
|
||
.. ... ln) }()
|
||
128 log.Printf("listening on http:/
|
||
.. ... /%s (version %s, audio sink %s, mut
|
||
.. ... e=%v)",
|
||
129 addr, versionString(), sink
|
||
.. ... .Name(), mute.Muted())
|
||
130
|
||
131 sig := make(chan os.Signal, 1)
|
||
132 signal.Notify(sig, os.Interrupt
|
||
.. ... , syscall.SIGTERM)
|
||
133
|
||
134 select {
|
||
135 case err := <-errCh:
|
||
136 q.Close()
|
||
137 return fmt.Errorf("http ser
|
||
.. ... ver: %w", err)
|
||
138 case s := <-sig:
|
||
139 log.Printf("Signal %v empfa
|
||
.. ... ngen — fahre herunter (laufende Ans
|
||
.. ... age wird abgebrochen)", s)
|
||
140 }
|
||
141
|
||
142 // Reihenfolge: erst HTTP dicht
|
||
.. ... (keine neuen Anfragen), dann die Q
|
||
.. ... ueue
|
||
143 // schließen (laufende Ansage a
|
||
.. ... bbrechen, Worker beenden und darauf
|
||
144 // warten). So läuft nie eine A
|
||
.. ... nsage nach dem Daemon-Ende weiter.
|
||
145 shutdownCtx, cancel := context.
|
||
.. ... WithTimeout(context.Background(), 3
|
||
.. ... *time.Second)
|
||
146 defer cancel()
|
||
147 if err := srv.Shutdown(shutdown
|
||
.. ... Ctx); err != nil {
|
||
148 log.Printf("http shutdown:
|
||
.. ... %v", err)
|
||
149 }
|
||
150 q.Close()
|
||
151 return nil
|
||
152 }
|
||
153
|
||
154 // versionString rendert die Daemon
|
||
.. ... -Version für /status.
|
||
155 func versionString() string {
|
||
156 if commit == "" {
|
||
157 return version
|
||
158 }
|
||
159 return fmt.Sprintf("%s (%s)", v
|
||
.. ... ersion, commit)
|
||
160 }
|
||
161
|
||
162 // chainConfigFor überführt Config
|
||
.. ... + Item-Pins in die ChainConfig — di
|
||
.. ... eselbe
|
||
163 // Zusammensetzung wie im CLI-Pfad
|
||
.. ... (cmd/talk/say.go), nur dass der Dae
|
||
.. ... mon
|
||
164 // sie pro Ansage baut (Pin/Voice s
|
||
.. ... ind Anfrage-Properties). player ist
|
||
.. ... nur
|
||
165 // für die Abspiel-Kette gesetzt (d
|
||
.. ... ie Basiskette braucht ihn nicht).
|
||
166 func chainConfigFor(cfg config.Conf
|
||
.. ... ig, pin, voice string, getenv func(
|
||
.. ... string) string, player provider.Pla
|
||
.. ... yer) provider.ChainConfig {
|
||
167 return provider.ChainConfig{
|
||
168 Pin: pickProvider(pin
|
||
.. ... , cfg.Provider),
|
||
169 Voice: voice,
|
||
170 Voices: cfg.Voice,
|
||
171 Endpoints: endpointsOf(cfg)
|
||
.. ... ,
|
||
172 Model: cfg.Providers["o
|
||
.. ... penai"].Model,
|
||
173 Getenv: getenv,
|
||
174 Player: player,
|
||
175 Log: lineLogger{},
|
||
176 }
|
||
177 }
|
||
178
|
||
179 // pickProvider löst den Provider-P
|
||
.. ... in auf: Anfrage-/Flag-Pin schlägt
|
||
180 // `provider = "…"` aus der config.
|
||
.. ... toml; leer = komplette Default-Kett
|
||
.. ... e.
|
||
181 func pickProvider(pin, cfgValue str
|
||
.. ... ing) string {
|
||
182 if pin != "" {
|
||
183 return pin
|
||
184 }
|
||
185 return cfgValue
|
||
186 }
|
||
187
|
||
188 // endpointsOf überführt die Provid
|
||
.. ... er-Einstellungen der Config in die
|
||
189 // Endpunkt-Map der Kette.
|
||
190 func endpointsOf(cfg config.Config)
|
||
.. ... map[string]string {
|
||
191 endpoints := make(map[string]st
|
||
.. ... ring, len(cfg.Providers))
|
||
192 for name, settings := range cfg
|
||
.. ... .Providers {
|
||
193 if settings.Endpoint != ""
|
||
.. ... {
|
||
194 endpoints[name] = setti
|
||
.. ... ngs.Endpoint
|
||
195 }
|
||
196 }
|
||
197 return endpoints
|
||
198 }
|
||
199
|
||
200 // resolveProviderName löst den „ak
|
||
.. ... tiven Provider" für /status auf: Pi
|
||
.. ... n des
|
||
201 // Items, sonst erste verfügbare St
|
||
.. ... ufe der Basiskette. Available() ist
|
||
202 // bewusst billig (Key-/Binary-Prüf
|
||
.. ... ung ohne Netz-Call), deshalb darf d
|
||
.. ... as
|
||
203 // /status on demand aufrufen.
|
||
204 func resolveProviderName(chain *pro
|
||
.. ... vider.Chain, item queue.Item) strin
|
||
.. ... g {
|
||
205 if item.Provider != "" {
|
||
206 return item.Provider
|
||
207 }
|
||
208 for _, p := range chain.Tiers()
|
||
.. ... {
|
||
209 if p.Available(context.Back
|
||
.. ... ground()) {
|
||
210 return p.Name()
|
||
211 }
|
||
212 }
|
||
213 return "keine"
|
||
214 }
|
||
215
|
||
216 // providerState merkt sich den zul
|
||
.. ... etzt aktiven Provider (für /status
|
||
.. ... im
|
||
217 // Leerlauf). Geschrieben wird auss
|
||
.. ... chließlich vom Queue-Worker, gelese
|
||
.. ... n von
|
||
218 // HTTP-Handlern — daher der eigene
|
||
.. ... kleine Mutex.
|
||
219 type providerState struct {
|
||
220 mu sync.Mutex
|
||
221 name string
|
||
222 }
|
||
223
|
||
224 func (s *providerState) set(name st
|
||
.. ... ring) {
|
||
225 s.mu.Lock()
|
||
226 s.name = name
|
||
227 s.mu.Unlock()
|
||
228 }
|
||
229
|
||
230 func (s *providerState) get() strin
|
||
.. ... g {
|
||
231 s.mu.Lock()
|
||
232 defer s.mu.Unlock()
|
||
233 return s.name
|
||
234 }
|
||
235
|
||
236 // lineLogger reicht Zeilen an den
|
||
.. ... Standard-Logger weiter (Zeitstempel
|
||
.. ... +
|
||
237 // talkd-Präfix), statt sie roh zu
|
||
.. ... schreiben: Provider-Kette, Queue un
|
||
.. ... d
|
||
238 // null-Sink schreiben zeilenweise,
|
||
.. ... so entsteht ein einheitliches Log.
|
||
239 type lineLogger struct{}
|
||
240
|
||
241 func (lineLogger) Write(p []byte) (
|
||
.. ... int, error) {
|
||
242 for _, line := range strings.Sp
|
||
.. ... lit(strings.TrimRight(string(p), "\
|
||
.. ... n"), "\n") {
|
||
243 if line != "" {
|
||
244 log.Print(line)
|
||
245 }
|
||
246 }
|
||
247 return len(p), nil
|
||
248 }
|
||
|
||
internal/api/client.go --- Go
|
||
1 package api
|
||
2
|
||
3 import (
|
||
4 "bytes"
|
||
5 "context"
|
||
6 "encoding/json"
|
||
7 "fmt"
|
||
8 "io"
|
||
9 "net/http"
|
||
10 "strings"
|
||
11 "time"
|
||
12 )
|
||
13
|
||
14 // Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
|
||
15 // dieselbe Vertragsschnittstelle, die server.go bedient.
|
||
16 type Client struct {
|
||
17 // BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
|
||
18 BaseURL string
|
||
19
|
||
20 // HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
|
||
21 HTTP *http.Client
|
||
22 }
|
||
23
|
||
24 // NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
|
||
25 // Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
|
||
26 // großzügiger); das Client-Timeout ist nur die harte Decke.
|
||
27 func NewClient(port int) *Client {
|
||
28 return &Client{
|
||
29 BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||
30 HTTP: &http.Client{Timeout: 10 * time.Second},
|
||
31 }
|
||
32 }
|
||
33
|
||
34 // HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
|
||
35 // 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
|
||
36 // *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
|
||
37 // Netz-Fehler → Degraded Mode.
|
||
38 type HTTPError struct {
|
||
39 Status int
|
||
40 Body string
|
||
41 }
|
||
42
|
||
43 func (e *HTTPError) Error() string {
|
||
44 return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
|
||
45 }
|
||
46
|
||
47 // Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
|
||
48 func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
|
||
49 var st StatusResponse
|
||
50 if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
|
||
51 return nil, err
|
||
52 }
|
||
53 return &st, nil
|
||
54 }
|
||
55
|
||
56 // Speak ruft POST /speak (enqueue).
|
||
57 func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
|
||
58 var resp SpeakResponse
|
||
59 if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
|
||
60 return nil, err
|
||
61 }
|
||
62 return &resp, nil
|
||
63 }
|
||
64
|
||
65 // Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
|
||
66 // Ansagen.
|
||
67 func (c *Client) Cancel(ctx context.Context) (int, error) {
|
||
68 var resp CancelResponse
|
||
69 if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
|
||
70 return 0, err
|
||
71 }
|
||
72 return resp.Cancelled, nil
|
||
73 }
|
||
74
|
||
75 // SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
|
||
76 func (c *Client) SetMute(ctx context.Context, muted bool) error {
|
||
77 path := "/mute"
|
||
78 if !muted {
|
||
79 path = "/unmute"
|
||
80 }
|
||
81 return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
|
||
82 }
|
||
83
|
||
84 // do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
|
||
85 // dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
||
86 // über errors.As(&HTTPError)).
|
||
87 func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
|
||
88 var rd io.Reader
|
||
89 if body != nil {
|
||
90 b, err := json.Marshal(body)
|
||
91 if err != nil {
|
||
92 return err
|
||
93 }
|
||
94 rd = bytes.NewReader(b)
|
||
95 }
|
||
96 req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||
97 if err != nil {
|
||
98 return err
|
||
99 }
|
||
100 if body != nil {
|
||
101 req.Header.Set("Content-Type", "application/json")
|
||
102 }
|
||
103 client := c.HTTP
|
||
104 if client == nil {
|
||
105 client = http.DefaultClient
|
||
106 }
|
||
107 resp, err := client.Do(req)
|
||
108 if err != nil {
|
||
109 return err
|
||
110 }
|
||
111 defer resp.Body.Close()
|
||
112 data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||
113 if err != nil {
|
||
114 return err
|
||
115 }
|
||
116 if resp.StatusCode != wantStatus {
|
||
117 return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
||
118 }
|
||
119 if out == nil {
|
||
120 return nil
|
||
121 }
|
||
122 return json.Unmarshal(data, out)
|
||
123 }
|
||
124
|
||
|
||
internal/api/doc.go --- Go
|
||
1 // Package api implementiert die HTTP 1 // Package api definiert die HTTP-Obe
|
||
. -Oberfläche des Daemons auf 127.0.0.1 . rfläche des Daemons auf 127.0.0.1:
|
||
. : .
|
||
. 2 // POST /speak (enqueue, 202 {id}; be
|
||
. . i Mute 202 {muted:true}), GET /status
|
||
. . ,
|
||
2 // POST /speak, GET /status, POST /ca 3 // POST /cancel, POST /mute, POST /un
|
||
. ncel, POST /mute, POST /unmute (Task . mute (Task 04). CORS für /speak und
|
||
. 04), .
|
||
3 // CORS für /speak und den MCP-Endpoi 4 // der MCP-Endpoint (Streamable HTTP)
|
||
. nt (Streamable HTTP, Task 06). . folgen in Task 06. Der Client-Teil
|
||
. 5 // ist die CLI-Seite derselben Vertra
|
||
. . gsschnittstelle.
|
||
4 package api 6 package api
|
||
|
||
internal/api/server.go --- Go
|
||
1 package api
|
||
2
|
||
3 import (
|
||
4 "encoding/json"
|
||
5 "fmt"
|
||
6 "net/http"
|
||
7 "strings"
|
||
8
|
||
9 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||
10 )
|
||
11
|
||
12 // maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
|
||
13 // versehentliche Riesen-Payloads).
|
||
14 const maxBodyBytes = 64 << 10
|
||
15
|
||
16 // HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
|
||
17 // kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
|
||
18 // injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
|
||
19 type HandlerConfig struct {
|
||
20 // Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
|
||
21 Queue *queue.Queue
|
||
22
|
||
23 // Mute ist der persistente Mute-State (Datei mute im Config-Dir).
|
||
24 Mute queue.MuteStore
|
||
25
|
||
26 // Version ist die Daemon-Version für GET /status.
|
||
27 Version string
|
||
28
|
||
29 // KnownProviders sind die gültigen Provider-Namen (Validierung des
|
||
30 // provider-Felds in POST /speak; talkd liefert provider.Names()).
|
||
31 KnownProviders []string
|
||
32
|
||
33 // Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
|
||
34 // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
|
||
35 Provider func() string
|
||
36 }
|
||
37
|
||
38 // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||
39 // ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
|
||
40 func NewHandler(cfg HandlerConfig) http.Handler {
|
||
41 h := &handler{
|
||
42 queue: cfg.Queue,
|
||
43 mute: cfg.Mute,
|
||
44 version: cfg.Version,
|
||
45 known: make(map[string]bool, len(cfg.KnownProviders)),
|
||
46 provider: cfg.Provider,
|
||
47 }
|
||
48 for _, name := range cfg.KnownProviders {
|
||
49 h.known[name] = true
|
||
50 }
|
||
51 mux := http.NewServeMux()
|
||
52 mux.HandleFunc("POST /speak", h.speak)
|
||
53 mux.HandleFunc("GET /status", h.status)
|
||
54 mux.HandleFunc("POST /cancel", h.cancel)
|
||
55 mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||
56 mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||
57 return mux
|
||
58 }
|
||
59
|
||
60 // handler ist der interne Zustand der HTTP-Oberfläche.
|
||
61 type handler struct {
|
||
62 queue *queue.Queue
|
||
63 mute queue.MuteStore
|
||
64 version string
|
||
65 known map[string]bool
|
||
66 provider func() string
|
||
67 }
|
||
68
|
||
69 // speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
||
70 // dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
|
||
71 // Sonderbehandlung brauchen), sonst enqueue → 202 {id}.
|
||
72 func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
|
||
73 var req SpeakRequest
|
||
74 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||
75 writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
|
||
76 return
|
||
77 }
|
||
78 if strings.TrimSpace(req.Text) == "" {
|
||
79 writeError(w, http.StatusBadRequest, "text fehlt oder ist leer")
|
||
80 return
|
||
81 }
|
||
82 if req.Priority == "" {
|
||
83 req.Priority = string(queue.PriorityNormal)
|
||
84 }
|
||
85 if !queue.Priority(req.Priority).Valid() {
|
||
86 writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority))
|
||
87 return
|
||
88 }
|
||
89 if req.Provider != "" && !h.known[req.Provider] {
|
||
90 writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider))
|
||
91 return
|
||
92 }
|
||
93 if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
|
||
94 writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang))
|
||
95 return
|
||
96 }
|
||
97
|
||
98 if h.mute.Muted() {
|
||
99 writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true})
|
||
100 return
|
||
101 }
|
||
102
|
||
103 id, err := h.queue.Enqueue(queue.Item{
|
||
104 Text: req.Text,
|
||
105 Lang: req.Lang,
|
||
106 Provider: req.Provider,
|
||
107 Voice: req.Voice,
|
||
108 Priority: queue.Priority(req.Priority),
|
||
109 Now: req.Now,
|
||
110 })
|
||
111 if err != nil {
|
||
112 writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error())
|
||
113 return
|
||
114 }
|
||
115 writeJSON(w, http.StatusAccepted, SpeakResponse{ID: id})
|
||
116 }
|
||
117
|
||
118 // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
|
||
119 // Item, Mute-State, aktiver Provider, Version.
|
||
120 func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||
121 pending := h.queue.Pending()
|
||
122 ids := make([]string, len(pending))
|
||
123 for i, it := range pending {
|
||
124 ids[i] = it.ID
|
||
125 }
|
||
126 st := StatusResponse{
|
||
127 Version: h.version,
|
||
128 Queue: len(pending),
|
||
129 QueueIDs: ids,
|
||
130 Muted: h.mute.Muted(),
|
||
131 }
|
||
132 if cur, ok := h.queue.Current(); ok {
|
||
133 st.Current = &CurrentItem{
|
||
134 ID: cur.ID,
|
||
135 Text: cur.Text,
|
||
136 Priority: string(cur.Priority),
|
||
137 Now: cur.Now,
|
||
138 }
|
||
139 }
|
||
140 if h.provider != nil {
|
||
141 st.Provider = h.provider()
|
||
142 }
|
||
143 writeJSON(w, http.StatusOK, st)
|
||
144 }
|
||
145
|
||
146 // cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
|
||
147 func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
|
||
148 writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
|
||
149 }
|
||
150
|
||
151 // muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
|
||
152 // Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
|
||
153 func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
||
154 return func(w http.ResponseWriter, r *http.Request) {
|
||
155 if err := h.mute.Set(muted); err != nil {
|
||
156 writeError(w, http.StatusInternalServerError, err.Error())
|
||
157 return
|
||
158 }
|
||
159 writeJSON(w, http.StatusOK, MuteResponse{Muted: muted})
|
||
160 }
|
||
161 }
|
||
162
|
||
163 // writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
||
164 func writeJSON(w http.ResponseWriter, code int, body any) {
|
||
165 w.Header().Set("Content-Type", "application/json")
|
||
166 w.WriteHeader(code)
|
||
167 _ = json.NewEncoder(w).Encode(body)
|
||
168 }
|
||
169
|
||
170 // writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
|
||
171 func writeError(w http.ResponseWriter, code int, msg string) {
|
||
172 writeJSON(w, code, map[string]string{"error": msg})
|
||
173 }
|
||
174
|
||
|
||
internal/api/types.go --- Go
|
||
1 package api
|
||
2
|
||
3 // Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
|
||
4 // Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
|
||
5 // Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
|
||
6
|
||
7 // SpeakRequest ist der Body von POST /speak.
|
||
8 type SpeakRequest struct {
|
||
9 // Text ist der Ansage-Text (Pflicht).
|
||
10 Text string `json:"text"`
|
||
11
|
||
12 // Priority: low | normal | high (Default normal, wenn leer).
|
||
13 Priority string `json:"priority,omitempty"`
|
||
14
|
||
15 // Provider pinnt die Provider-Kette (--provider).
|
||
16 Provider string `json:"provider,omitempty"`
|
||
17
|
||
18 // Voice pinnt eine Voice-ID (--voice).
|
||
19 Voice string `json:"voice,omitempty"`
|
||
20
|
||
21 // Lang pinnt die Sprache de | en (leer = automatische Erkennung).
|
||
22 Lang string `json:"lang,omitempty"`
|
||
23
|
||
24 // Now lässt die Ansage vor allen wartenden springen (--now).
|
||
25 Now bool `json:"now,omitempty"`
|
||
26 }
|
||
27
|
||
28 // SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
|
||
29 // aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
|
||
30 // Verhalten, kein Fehler).
|
||
31 type SpeakResponse struct {
|
||
32 ID string `json:"id"`
|
||
33 Muted bool `json:"muted,omitempty"`
|
||
34 }
|
||
35
|
||
36 // CurrentItem beschreibt das laufende Item in GET /status.
|
||
37 type CurrentItem struct {
|
||
38 ID string `json:"id"`
|
||
39 Text string `json:"text"`
|
||
40 Priority string `json:"priority"`
|
||
41 Now bool `json:"now"`
|
||
42 }
|
||
43
|
||
44 // StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
|
||
45 // Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
|
||
46 // QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
|
||
47 // — die CLI braucht sie für ihr --wait-Polling.
|
||
48 type StatusResponse struct {
|
||
49 Version string `json:"version"`
|
||
50 Queue int `json:"queue"`
|
||
51 QueueIDs []string `json:"queue_ids,omitempty"`
|
||
52 Current *CurrentItem `json:"current"`
|
||
53 Muted bool `json:"muted"`
|
||
54 Provider string `json:"provider"`
|
||
55 }
|
||
56
|
||
57 // PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
|
||
58 // gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
|
||
59 // weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
|
||
60 // beiden Fällen hat der Aufrufer nichts mehr zu warten.
|
||
61 func (s StatusResponse) PendingOrCurrent(id string) bool {
|
||
62 if s.Current != nil && s.Current.ID == id {
|
||
63 return true
|
||
64 }
|
||
65 for _, qid := range s.QueueIDs {
|
||
66 if qid == id {
|
||
67 return true
|
||
68 }
|
||
69 }
|
||
70 return false
|
||
71 }
|
||
72
|
||
73 // CancelResponse ist die Antwort von POST /cancel.
|
||
74 type CancelResponse struct {
|
||
75 Cancelled int `json:"cancelled"`
|
||
76 }
|
||
77
|
||
78 // MuteResponse ist die Antwort von POST /mute und POST /unmute.
|
||
79 type MuteResponse struct {
|
||
80 Muted bool `json:"muted"`
|
||
81 }
|
||
82
|
||
|
||
internal/config/config.go --- 1/5 --- Go
|
||
5 5 "fmt"
|
||
6 6 "os"
|
||
7 7 "path/filepath"
|
||
. 8 "strconv"
|
||
8 9
|
||
9 10 "github.com/BurntSushi/toml"
|
||
10 11 )
|
||
|
||
internal/config/config.go --- 2/5 --- Go
|
||
16 SinkNull = "null" 17 SinkNull = "null"
|
||
17 ) 18 )
|
||
.. 19
|
||
.. 20 // DefaultServerPort ist der Defaul
|
||
.. .. t-Port des Daemon-HTTP-Servers auf
|
||
.. 21 // 127.0.0.1 (Task-04-Brief: „Port
|
||
.. .. Default 7800").
|
||
.. 22 const DefaultServerPort = 7800
|
||
18 23
|
||
19 // Config ist der effektiv geladene 24 // Config ist der effektiv geladene
|
||
.. Konfigurations-Stand von talk. Tas .. Konfigurations-Stand von talk. Tas
|
||
.. k 02 .. k 02
|
||
20 // führt audio.sink; Task 03 ergänz 25 // führt audio.sink; Task 03 ergänz
|
||
.. t den Provider-Pin, Provider-Endpun .. t den Provider-Pin, Provider-Endpun
|
||
.. kte .. kte
|
||
21 // und das Voice-Mapping; spätere T 26 // und das Voice-Mapping; Task 04 d
|
||
.. asks ergänzen weitere Felder (serve .. en Daemon-Port (server.port, TALK_P
|
||
.. r, .. ORT);
|
||
22 // queue …), ohne die Auflösungsrei 27 // spätere Tasks ergänzen weitere F
|
||
.. henfolge zu ändern. .. elder (queue …), ohne die Auflösung
|
||
.. .. s-
|
||
.. 28 // reihenfolge zu ändern.
|
||
23 type Config struct { 29 type Config struct {
|
||
24 Audio Audio `toml:"audio"` 30 Audio Audio `toml:"audio"`
|
||
.. 31
|
||
.. 32 // Server steuert den Daemon-HT
|
||
.. .. TP-Server (talkd) auf 127.0.0.1.
|
||
.. 33 Server Server `toml:"server"`
|
||
25 34
|
||
26 // Provider pinnt die Kette auf 35 // Provider pinnt die Kette auf
|
||
.. genau einen Provider (z. B. .. genau einen Provider (z. B.
|
||
27 // `provider = "elevenlabs"`); 36 // `provider = "elevenlabs"`);
|
||
.. leer = komplette Default-Kette .. leer = komplette Default-Kette
|
||
|
||
internal/config/config.go --- 3/5 --- Go
|
||
48 57 Sink string `toml:"sink"`
|
||
49 58 }
|
||
.. 59
|
||
.. 60 // Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding
|
||
.. 61 // auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port).
|
||
.. 62 type Server struct {
|
||
.. 63 // Port des Daemon-HTTP-Servers auf 127.0.0.1 — config
|
||
.. 64 // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
|
||
.. 65 Port int `toml:"port"`
|
||
.. 66 }
|
||
50 67
|
||
51 68 // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
|
||
52 69 // Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
|
||
|
||
internal/config/config.go --- 4/5 --- Go
|
||
63 80 // der Auflösung).
|
||
64 81 func Defaults() Config {
|
||
65 82 return Config{
|
||
.. 83 Audio: Audio{Sink: SinkOto},
|
||
.. 84 Server: Server{Port: DefaultServerPort},
|
||
.. 85 }
|
||
66 86 }
|
||
67 87
|
||
|
||
internal/config/config.go --- 5/5 --- Go
|
||
116 136 if v := getenv("TALK_PROVIDER"); v != "" {
|
||
117 137 cfg.Provider = v
|
||
118 138 }
|
||
... 139 if v := getenv("TALK_PORT"); v != "" {
|
||
... 140 port, err := strconv.Atoi(v)
|
||
... 141 if err != nil || port < 1 || port > 65535 {
|
||
... 142 return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v)
|
||
... 143 }
|
||
... 144 cfg.Server.Port = port
|
||
... 145 }
|
||
119 146 switch cfg.Audio.Sink {
|
||
120 147 case SinkOto, SinkNull:
|
||
121 148 return cfg, nil
|
||
|
||
internal/config/config_test.go --- Go
|
||
289 289 })
|
||
290 290 }
|
||
291
|
||
292 // TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
|
||
293 // (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
|
||
294 func TestLoadServerPort(t *testing.T) {
|
||
295 writeTOML := func(t *testing.T, content string) string {
|
||
296 t.Helper()
|
||
297 path := filepath.Join(t.TempDir(), "config.toml")
|
||
298 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||
299 t.Fatal(err)
|
||
300 }
|
||
301 return path
|
||
302 }
|
||
303
|
||
304 t.Run("Default 7800", func(t *testing.T) {
|
||
305 cfg, err := Load(Options{
|
||
306 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
||
307 Getenv: env(nil),
|
||
308 })
|
||
309 if err != nil {
|
||
310 t.Fatalf("Load: %v", err)
|
||
311 }
|
||
312 if cfg.Server.Port != DefaultServerPort {
|
||
313 t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort)
|
||
314 }
|
||
315 })
|
||
316
|
||
317 t.Run("TOML überschreibt Default", func(t *testing.T) {
|
||
318 cfg, err := Load(Options{
|
||
319 File: writeTOML(t, "[server]\nport = 7999\n"),
|
||
320 Getenv: env(nil),
|
||
321 })
|
||
322 if err != nil {
|
||
323 t.Fatalf("Load: %v", err)
|
||
324 }
|
||
325 if cfg.Server.Port != 7999 {
|
||
326 t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port)
|
||
327 }
|
||
328 })
|
||
329
|
||
330 t.Run("TALK_PORT schlägt Datei", func(t *testing.T) {
|
||
331 cfg, err := Load(Options{
|
||
332 File: writeTOML(t, "[server]\nport = 7999\n"),
|
||
333 Getenv: env(map[string]string{"TALK_PORT": "7912"}),
|
||
334 })
|
||
335 if err != nil {
|
||
336 t.Fatalf("Load: %v", err)
|
||
337 }
|
||
338 if cfg.Server.Port != 7912 {
|
||
339 t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port)
|
||
340 }
|
||
341 })
|
||
342
|
||
343 t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) {
|
||
344 for _, bad := range []string{"null", "0", "-1", "70000"} {
|
||
345 _, err := Load(Options{
|
||
346 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
||
347 Getenv: env(map[string]string{"TALK_PORT": bad}),
|
||
348 })
|
||
349 if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
|
||
350 t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err)
|
||
351 }
|
||
352 }
|
||
353 })
|
||
354 }
|
||
|
||
internal/queue/doc.go --- Go
|
||
1 // Package queue implementiert die An 1 // Package queue implementiert die An
|
||
. sage-Queue des Daemons: serialisierte . sage-Queue des Daemons: serialisierte
|
||
2 // Abarbeitung (eine Ansage gleichzei 2 // Abarbeitung (eine Ansage gleichzei
|
||
. tig), Prioritäten (high > normal > lo . tig), Prioritäten (high > normal > lo
|
||
. w, . w,
|
||
3 // FIFO je Level), now-Items springen 3 // FIFO je Level), now-Items springen
|
||
. vor, Rate-Limit mit prioritätsbewuss . vor, dazu den persistenten Mute-Stat
|
||
. tem . e
|
||
4 // Verwerfen. Implementierung folgt i 4 // (Datei mute im Config-Dir) und die
|
||
. n Task 04 (Queue) und Task 05 . Rate-Limit-Struktur. Das Rate-Limit
|
||
5 // (Rate-Limit, Ruhezeiten). 5 // ist als Struktur vorhanden, aber n
|
||
. . och nicht angebunden — Wiring und
|
||
. 6 // Ruhezeiten folgen in Task 05.
|
||
6 package queue 7 package queue
|
||
|
||
internal/queue/mute.go --- Go
|
||
1 package queue
|
||
2
|
||
3 import (
|
||
4 "errors"
|
||
5 "os"
|
||
6 "path/filepath"
|
||
7 "strings"
|
||
8 )
|
||
9
|
||
10 // MuteFileName ist der Name der Mute-Datei im Config-Verzeichnis (bindender
|
||
11 // Vertrag: „persistenten Mute-State toggeln (Datei mute im Config-Dir)").
|
||
12 const MuteFileName = "mute"
|
||
13
|
||
14 // ErrNoMutePath meldet einen Set-Versuch ohne konfigurierten Datei-Pfad
|
||
15 // (Config-Dir nicht bestimmbar — der Daemon deaktiviert dann Mute mit einer
|
||
16 // Start-Warnung statt zu scheitern).
|
||
17 var ErrNoMutePath = errors.New("mute: kein Datei-Pfad konfiguriert (Config-Dir nicht bestimmbar)")
|
||
18
|
||
19 // MuteStore verwaltet den persistenten Mute-State als Datei im
|
||
20 // Config-Verzeichnis: Inhalt „true" = stumm, alles andere (oder keine Datei)
|
||
21 // = nicht stumm.
|
||
22 //
|
||
23 // Der Daemon liest die Datei bei jeder /speak-Anfrage neu — dadurch wirkt
|
||
24 // auch ein im Degraded Mode (Daemon tot) von der CLI direkt geschriebener
|
||
25 // State sofort, und der Zustand überlebt Daemon-Restarts.
|
||
26 type MuteStore struct {
|
||
27 // Path ist der vollständige Pfad der Mute-Datei. Leer (kein Config-Dir
|
||
28 // bestimmbar): Muted() ist immer false, Set ein Fehler.
|
||
29 Path string
|
||
30 }
|
||
31
|
||
32 // Muted meldet den persistenten Mute-State. Eine fehlende oder unlesbare
|
||
33 // Datei heißt: nicht stumm (Fehlstart ohne Config-Dir wird nie „stumm aus
|
||
34 // Versehen").
|
||
35 func (s MuteStore) Muted() bool {
|
||
36 if s.Path == "" {
|
||
37 return false
|
||
38 }
|
||
39 b, err := os.ReadFile(s.Path)
|
||
40 if err != nil {
|
||
41 return false
|
||
42 }
|
||
43 return strings.TrimSpace(string(b)) == "true"
|
||
44 }
|
||
45
|
||
46 // Set schreibt den Mute-State atomar (temporäre Datei + Rename, damit
|
||
47 // Daemon und CLI nie eine halb geschriebene Datei lesen) und legt das
|
||
48 // Config-Verzeichnis bei Bedarf an.
|
||
49 func (s MuteStore) Set(muted bool) error {
|
||
50 if s.Path == "" {
|
||
51 return ErrNoMutePath
|
||
52 }
|
||
53 dir := filepath.Dir(s.Path)
|
||
54 if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
55 return err
|
||
56 }
|
||
57 content := "false\n"
|
||
58 if muted {
|
||
59 content = "true\n"
|
||
60 }
|
||
61 tmp, err := os.CreateTemp(dir, ".mute-*")
|
||
62 if err != nil {
|
||
63 return err
|
||
64 }
|
||
65 name := tmp.Name()
|
||
66 if _, err := tmp.WriteString(content); err != nil {
|
||
67 tmp.Close()
|
||
68 os.Remove(name)
|
||
69 return err
|
||
70 }
|
||
71 if err := tmp.Close(); err != nil {
|
||
72 os.Remove(name)
|
||
73 return err
|
||
74 }
|
||
75 if err := os.Chmod(name, 0o644); err != nil {
|
||
76 os.Remove(name)
|
||
77 return err
|
||
78 }
|
||
79 if err := os.Rename(name, s.Path); err != nil {
|
||
80 os.Remove(name)
|
||
81 return err
|
||
82 }
|
||
83 return nil
|
||
84 }
|
||
85
|
||
|
||
internal/queue/queue.go --- Go
|
||
1 package queue
|
||
2
|
||
3 import (
|
||
4 "context"
|
||
5 "crypto/rand"
|
||
6 "encoding/hex"
|
||
7 "errors"
|
||
8 "fmt"
|
||
9 "io"
|
||
10 "sort"
|
||
11 "sync"
|
||
12 "time"
|
||
13 )
|
||
14
|
||
15 // Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
|
||
16 // (low | normal | high; leer wird als normal behandelt).
|
||
17 type Priority string
|
||
18
|
||
19 // Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
|
||
20 const (
|
||
21 PriorityLow Priority = "low"
|
||
22 PriorityNormal Priority = "normal"
|
||
23 PriorityHigh Priority = "high"
|
||
24 )
|
||
25
|
||
26 // Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
|
||
27 func (p Priority) Valid() bool {
|
||
28 switch p {
|
||
29 case PriorityLow, PriorityNormal, PriorityHigh:
|
||
30 return true
|
||
31 default:
|
||
32 return false
|
||
33 }
|
||
34 }
|
||
35
|
||
36 // ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
|
||
37 var ErrClosed = errors.New("queue: bereits geschlossen")
|
||
38
|
||
39 // Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
|
||
40 // HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
|
||
41 type Item struct {
|
||
42 // ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
|
||
43 // des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
|
||
44 ID string
|
||
45
|
||
46 // Text ist der Ansage-Text.
|
||
47 Text string
|
||
48
|
||
49 // Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
|
||
50 // durch den Daemon (langdetect).
|
||
51 Lang string
|
||
52
|
||
53 // Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
|
||
54 // = konfigurierte Default-Kette.
|
||
55 Provider string
|
||
56
|
||
57 // Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
|
||
58 Voice string
|
||
59
|
||
60 // Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
|
||
61 // FIFO je Level).
|
||
62 Priority Priority
|
||
63
|
||
64 // Now lässt die Ansage vor alle wartenden Items springen (auch vor
|
||
65 // high) — sie unterbricht aber nie die laufende Ansage.
|
||
66 Now bool
|
||
67
|
||
68 seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
|
||
69 }
|
||
70
|
||
71 // rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
|
||
72 func (it Item) rank() int {
|
||
73 if it.Now {
|
||
74 return 3
|
||
75 }
|
||
76 switch it.Priority {
|
||
77 case PriorityHigh:
|
||
78 return 2
|
||
79 case PriorityNormal:
|
||
80 return 1
|
||
81 default:
|
||
82 return 0
|
||
83 }
|
||
84 }
|
||
85
|
||
86 // Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
|
||
87 // aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
|
||
88 // Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
|
||
89 // abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
|
||
90 // heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
|
||
91 // dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
|
||
92 type Speaker func(ctx context.Context, item Item) error
|
||
93
|
||
94 // Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
|
||
95 // gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level).
|
||
96 //
|
||
97 // Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
|
||
98 // clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
|
||
99 // Closed-Flag, cancelCur). Der Worker ist der einzige, der Items entnimmt;
|
||
100 // pop und das Setzen von current/cancelCur geschehen unter demselben Lock,
|
||
101 // damit ein Item nie zwischen „wartet" und „läuft" der Cancelierung
|
||
102 // entwischen kann. Cancel/Close rufen cancelCur unter dem Lock ab — der
|
||
103 // Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
|
||
104 type Queue struct {
|
||
105 speak Speaker
|
||
106 log io.Writer
|
||
107
|
||
108 mu sync.Mutex
|
||
109 notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close
|
||
110 items []Item
|
||
111 current *Item
|
||
112 cancelCur context.CancelFunc
|
||
113 seq uint64
|
||
114 closed bool
|
||
115 done chan struct{} // geschlossen, wenn der Worker-Loop endet
|
||
116 }
|
||
117
|
||
118 // New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
|
||
119 // log ist optional (nil = still) und empfängt die Lebenslauf-Zeilen der
|
||
120 // Items. Close beendet den Loop und wartet auf sein Ende.
|
||
121 func New(speak Speaker, log io.Writer) *Queue {
|
||
122 q := &Queue{speak: speak, log: log, done: make(chan struct{})}
|
||
123 q.notEmpty = sync.NewCond(&q.mu)
|
||
124 go q.loop()
|
||
125 return q
|
||
126 }
|
||
127
|
||
128 // Enqueue stellt item in die Queue und liefert dessen ID (bei leerer ID neu
|
||
129 // vergeben: kurz und zufällig). Nach Close ist Enqueue ein Fehler.
|
||
130 func (q *Queue) Enqueue(item Item) (string, error) {
|
||
131 q.mu.Lock()
|
||
132 if q.closed {
|
||
133 q.mu.Unlock()
|
||
134 return "", ErrClosed
|
||
135 }
|
||
136 if item.Priority == "" {
|
||
137 item.Priority = PriorityNormal
|
||
138 }
|
||
139 q.seq++
|
||
140 item.seq = q.seq
|
||
141 if item.ID == "" {
|
||
142 item.ID = newID()
|
||
143 }
|
||
144 q.items = append(q.items, item)
|
||
145 q.mu.Unlock()
|
||
146 q.notEmpty.Signal()
|
||
147 return item.ID, nil
|
||
148 }
|
||
149
|
||
150 // Pending liefert eine Momentaufnahme der wartenden Items in
|
||
151 // Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
|
||
152 func (q *Queue) Pending() []Item {
|
||
153 q.mu.Lock()
|
||
154 defer q.mu.Unlock()
|
||
155 out := make([]Item, len(q.items))
|
||
156 copy(out, q.items)
|
||
157 sort.SliceStable(out, func(i, j int) bool { return itemBefore(out[i], out[j]) })
|
||
158 return out
|
||
159 }
|
||
160
|
||
161 // itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
|
||
162 // innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
|
||
163 func itemBefore(a, b Item) bool {
|
||
164 if a.rank() != b.rank() {
|
||
165 return a.rank() > b.rank()
|
||
166 }
|
||
167 return a.seq < b.seq
|
||
168 }
|
||
169
|
||
170 // Current liefert das gerade laufende Item (false: Queue ist idle).
|
||
171 func (q *Queue) Current() (Item, bool) {
|
||
172 q.mu.Lock()
|
||
173 defer q.mu.Unlock()
|
||
174 if q.current == nil {
|
||
175 return Item{}, false
|
||
176 }
|
||
177 return *q.current, true
|
||
178 }
|
||
179
|
||
180 // Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
|
||
181 // die Zahl der abgebrochenen Ansagen (laufende + wartende).
|
||
182 func (q *Queue) Cancel() int {
|
||
183 q.mu.Lock()
|
||
184 defer q.mu.Unlock()
|
||
185 n := len(q.items)
|
||
186 q.items = nil
|
||
187 if q.current != nil {
|
||
188 n++
|
||
189 if q.cancelCur != nil {
|
||
190 q.cancelCur()
|
||
191 }
|
||
192 }
|
||
193 return n
|
||
194 }
|
||
195
|
||
196 // Close fährt die Queue herunter: wartende Items werden verworfen, die
|
||
197 // laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
|
||
198 // der Loop sicher beendet ist (idempotent).
|
||
199 func (q *Queue) Close() {
|
||
200 q.mu.Lock()
|
||
201 if q.closed {
|
||
202 q.mu.Unlock()
|
||
203 <-q.done
|
||
204 return
|
||
205 }
|
||
206 q.closed = true
|
||
207 q.items = nil
|
||
208 if q.cancelCur != nil {
|
||
209 q.cancelCur()
|
||
210 }
|
||
211 q.mu.Unlock()
|
||
212 q.notEmpty.Broadcast()
|
||
213 <-q.done
|
||
214 }
|
||
215
|
||
216 // loop ist der Worker: wartet auf Items und spielt sie einzeln ab. Nach
|
||
217 // Close (oder wenn Close während einer Ansage zuschlägt) beendet er sich,
|
||
218 // sobald die laufende Ansage zurückgekehrt ist.
|
||
219 func (q *Queue) loop() {
|
||
220 defer close(q.done)
|
||
221 for {
|
||
222 q.mu.Lock()
|
||
223 for len(q.items) == 0 && !q.closed {
|
||
224 q.notEmpty.Wait()
|
||
225 }
|
||
226 if q.closed {
|
||
227 q.mu.Unlock()
|
||
228 return
|
||
229 }
|
||
230 item := q.popLocked()
|
||
231 ctx, cancel := context.WithCancel(context.Background())
|
||
232 q.current = &item
|
||
233 q.cancelCur = cancel
|
||
234 q.mu.Unlock()
|
||
235
|
||
236 q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
|
||
237 start := time.Now()
|
||
238 err := q.speak(ctx, item)
|
||
239 dur := time.Since(start)
|
||
240 cancel()
|
||
241
|
||
242 q.mu.Lock()
|
||
243 q.current = nil
|
||
244 q.cancelCur = nil
|
||
245 q.mu.Unlock()
|
||
246
|
||
247 switch {
|
||
248 case ctx.Err() != nil:
|
||
249 q.logf("queue: ansage %s abgebrochen", item.ID)
|
||
250 case err != nil:
|
||
251 q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
|
||
252 default:
|
||
253 q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
|
||
254 }
|
||
255 }
|
||
256 }
|
||
257
|
||
258 // popLocked entnimmt das als nächstes fällige Item (mu muss gehalten werden).
|
||
259 func (q *Queue) popLocked() Item {
|
||
260 best := 0
|
||
261 for i := 1; i < len(q.items); i++ {
|
||
262 if itemBefore(q.items[i], q.items[best]) {
|
||
263 best = i
|
||
264 }
|
||
265 }
|
||
266 item := q.items[best]
|
||
267 q.items = append(q.items[:best], q.items[best+1:]...)
|
||
268 return item
|
||
269 }
|
||
270
|
||
271 // newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
|
||
272 // nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
|
||
273 // praktisch unerreichbar, aber deterministisch sicher.
|
||
274 func newID() string {
|
||
275 var b [3]byte
|
||
276 if _, err := rand.Read(b[:]); err == nil {
|
||
277 return hex.EncodeToString(b[:])
|
||
278 }
|
||
279 return fmt.Sprintf("id%d", time.Now().UnixNano())
|
||
280 }
|
||
281
|
||
282 // logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
|
||
283 // unter dem Mutex gerufen (Aufrufer achten darauf).
|
||
284 func (q *Queue) logf(format string, args ...any) {
|
||
285 if q.log != nil {
|
||
286 fmt.Fprintf(q.log, format+"\n", args...)
|
||
287 }
|
||
288 }
|
||
289
|
||
|
||
internal/queue/queue_test.go --- Go
|
||
1 package queue
|
||
2
|
||
3 import (
|
||
4 "context"
|
||
5 "os"
|
||
6 "path/filepath"
|
||
7 "slices"
|
||
8 "testing"
|
||
9 "time"
|
||
10 )
|
||
11
|
||
12 // gesteuerteSpielerin ist ein testbarer Speaker: Jede Ansage meldet ihren
|
||
13 // Start auf started (Kanal-Puffer: der Worker blockiert nie) und wartet
|
||
14 // dann, bis der Test sie per step freigibt — so ist die Abarbeitung
|
||
15 // vollständig deterministisch steuerbar. Ein abgebrochener ctx beendet das
|
||
16 // Warten (Cancel-/Close-Pfade). Alle Felder werden nur vom Test gelesen;
|
||
17 // started-Reihenfolge == Start-Reihenfolge.
|
||
18 type gesteuerteSpielerin struct {
|
||
19 started chan Item
|
||
20 step chan struct{}
|
||
21 consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
|
||
22 }
|
||
23
|
||
24 func neueSpielerin() *gesteuerteSpielerin {
|
||
25 return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
|
||
26 }
|
||
27
|
||
28 func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
|
||
29 g.started <- item
|
||
30 select {
|
||
31 case <-g.step:
|
||
32 return nil
|
||
33 case <-ctx.Done():
|
||
34 return ctx.Err()
|
||
35 }
|
||
36 }
|
||
37
|
||
38 // warteBis wartet, bis insgesamt n Ansagen gestartet sind, und liefert die
|
||
39 // seit dem letzten warteBis-Aufruf neu gestarteten.
|
||
40 func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
|
||
41 t.Helper()
|
||
42 var items []Item
|
||
43 deadline := time.After(2 * time.Second)
|
||
44 for g.consumed < n {
|
||
45 select {
|
||
46 case it := <-g.started:
|
||
47 g.consumed++
|
||
48 items = append(items, it)
|
||
49 case <-deadline:
|
||
50 t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
|
||
51 }
|
||
52 }
|
||
53 return items
|
||
54 }
|
||
55
|
||
56 // keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
|
||
57 func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
|
||
58 t.Helper()
|
||
59 select {
|
||
60 case it := <-g.started:
|
||
61 g.consumed++
|
||
62 t.Fatalf("unerwartete Ansage %q gestartet", it.Text)
|
||
63 case <-time.After(150 * time.Millisecond):
|
||
64 }
|
||
65 }
|
||
66
|
||
67 // TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
|
||
68 // Level. Der erste Blocker besetzt den Worker; danach werden alle Level
|
||
69 // gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
|
||
70 func TestAbarbeitungsReihenfolge(t *testing.T) {
|
||
71 g := neueSpielerin()
|
||
72 q := New(g.speak, nil)
|
||
73 defer q.Close()
|
||
74
|
||
75 erster, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
|
||
76 if err != nil {
|
||
77 t.Fatal(err)
|
||
78 }
|
||
79 if erster == "" {
|
||
80 t.Fatal("Enqueue vergibt keine ID")
|
||
81 }
|
||
82 g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
|
||
83
|
||
84 for _, it := range []Item{
|
||
85 {Text: "niedrig", Priority: PriorityLow},
|
||
86 {Text: "zweit-niedrig", Priority: PriorityLow},
|
||
87 {Text: "mittel", Priority: PriorityNormal},
|
||
88 {Text: "dringend", Priority: PriorityHigh},
|
||
89 {Text: "sofort", Now: true},
|
||
90 } {
|
||
91 if _, err := q.Enqueue(it); err != nil {
|
||
92 t.Fatal(err)
|
||
93 }
|
||
94 }
|
||
95 g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
|
||
96
|
||
97 // Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
|
||
98 close(g.step)
|
||
99 rest := g.warteBis(t, 6)
|
||
100
|
||
101 var texts []string
|
||
102 for _, it := range rest {
|
||
103 texts = append(texts, it.Text)
|
||
104 }
|
||
105 want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
|
||
106 if !slices.Equal(texts, want) {
|
||
107 t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
|
||
108 }
|
||
109 }
|
||
110
|
||
111 // TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
|
||
112 func TestCurrentUndPending(t *testing.T) {
|
||
113 g := neueSpielerin()
|
||
114 q := New(g.speak, nil)
|
||
115 defer q.Close()
|
||
116
|
||
117 if _, ok := q.Current(); ok {
|
||
118 t.Fatal("Current vor erster Ansage: want idle")
|
||
119 }
|
||
120
|
||
121 q.Enqueue(Item{Text: "blocker"})
|
||
122 g.warteBis(t, 1)
|
||
123
|
||
124 q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
|
||
125 q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
|
||
126
|
||
127 if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
|
||
128 t.Errorf("Current = %+v (ok=%v), will Blocker", cur, ok)
|
||
129 }
|
||
130 var pendingTexts []string
|
||
131 for _, it := range q.Pending() {
|
||
132 pendingTexts = append(pendingTexts, it.Text)
|
||
133 }
|
||
134 if !slices.Equal(pendingTexts, []string{"hoch", "niedrig"}) {
|
||
135 t.Errorf("Pending = %v, want [hoch niedrig]", pendingTexts)
|
||
136 }
|
||
137
|
||
138 close(g.step)
|
||
139 g.warteBis(t, 3)
|
||
140 }
|
||
141
|
||
142 // TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
|
||
143 // Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
|
||
144 // spielbereit.
|
||
145 func TestCancel(t *testing.T) {
|
||
146 g := neueSpielerin()
|
||
147 q := New(g.speak, nil)
|
||
148 defer q.Close()
|
||
149
|
||
150 q.Enqueue(Item{Text: "blocker"})
|
||
151 g.warteBis(t, 1)
|
||
152
|
||
153 q.Enqueue(Item{Text: "wartet-eins"})
|
||
154 q.Enqueue(Item{Text: "wartet-zwei"})
|
||
155
|
||
156 if n := q.Cancel(); n != 3 {
|
||
157 t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
|
||
158 }
|
||
159
|
||
160 g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
|
||
161 waitIdle(t, q)
|
||
162
|
||
163 if n := q.Cancel(); n != 0 {
|
||
164 t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
|
||
165 }
|
||
166
|
||
167 // Queue nimmt wieder an und spielt:
|
||
168 id, err := q.Enqueue(Item{Text: "danach"})
|
||
169 if err != nil {
|
||
170 t.Fatal(err)
|
||
171 }
|
||
172 g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
|
||
173 gestartet := g.warteBis(t, 2)
|
||
174 if len(gestartet) != 1 || gestartet[0].ID != id {
|
||
175 t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
|
||
176 }
|
||
177 }
|
||
178
|
||
179 // TestClose: wartende Items werden verworfen, die laufende Ansage über den
|
||
180 // abgebrochenen ctx beendet, Close wartet auf den Worker-Exit, danach ist
|
||
181 // Enqueue ein Fehler (und Close idempotent).
|
||
182 func TestClose(t *testing.T) {
|
||
183 g := neueSpielerin()
|
||
184 q := New(g.speak, nil)
|
||
185
|
||
186 q.Enqueue(Item{Text: "blocker"})
|
||
187 g.warteBis(t, 1)
|
||
188 q.Enqueue(Item{Text: "wartet"})
|
||
189
|
||
190 q.Close()
|
||
191
|
||
192 g.keineWeitere(t)
|
||
193 if _, ok := q.Current(); ok {
|
||
194 t.Error("Current nach Close: want idle")
|
||
195 }
|
||
196 if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
|
||
197 t.Error("Enqueue nach Close: want Fehler")
|
||
198 }
|
||
199 q.Close() // idempotent
|
||
200 }
|
||
201
|
||
202 // TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
|
||
203 func TestEnqueueVergibtIDs(t *testing.T) {
|
||
204 g := neueSpielerin()
|
||
205 q := New(g.speak, nil)
|
||
206 defer q.Close()
|
||
207
|
||
208 seen := map[string]bool{}
|
||
209 for i := 0; i < 20; i++ {
|
||
210 id, err := q.Enqueue(Item{Text: "x"})
|
||
211 if err != nil {
|
||
212 t.Fatal(err)
|
||
213 }
|
||
214 if len(id) < 4 || seen[id] {
|
||
215 t.Errorf("ID %q zu kurz oder doppelt", id)
|
||
216 }
|
||
217 seen[id] = true
|
||
218 }
|
||
219 }
|
||
220
|
||
221 // waitIdle pollt, bis kein Item mehr läuft (Cancel-/Close-Pfade geben das
|
||
222 // nach dem Worker-Exit synchron frei; das Pollen deckt die Übergangsphase
|
||
223 // ab, ohne auf Timing zu trauen).
|
||
224 func waitIdle(t *testing.T, q *Queue) {
|
||
225 t.Helper()
|
||
226 deadline := time.Now().Add(time.Second)
|
||
227 for {
|
||
228 if _, ok := q.Current(); !ok {
|
||
229 return
|
||
230 }
|
||
231 if time.Now().After(deadline) {
|
||
232 t.Fatal("Queue wird nicht idle")
|
||
233 }
|
||
234 time.Sleep(5 * time.Millisecond)
|
||
235 }
|
||
236 }
|
||
237
|
||
238 // TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares Set.
|
||
239 func TestMuteStore(t *testing.T) {
|
||
240 dir := t.TempDir()
|
||
241 path := filepath.Join(dir, MuteFileName)
|
||
242
|
||
243 var s MuteStore
|
||
244 if s.Muted() {
|
||
245 t.Error("MuteStore ohne Pfad: Muted() = true, want false")
|
||
246 }
|
||
247 if err := s.Set(true); err == nil {
|
||
248 t.Error("Set ohne Pfad: want Fehler")
|
||
249 }
|
||
250
|
||
251 s = MuteStore{Path: path}
|
||
252 if s.Muted() {
|
||
253 t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
|
||
254 }
|
||
255
|
||
256 if err := s.Set(true); err != nil {
|
||
257 t.Fatal(err)
|
||
258 }
|
||
259 if !s.Muted() {
|
||
260 t.Error("nach Set(true): Muted() = false")
|
||
261 }
|
||
262 if b, _ := os.ReadFile(path); string(b) != "true\n" {
|
||
263 t.Errorf("Datei-Inhalt = %q, want %q", b, "true\n")
|
||
264 }
|
||
265
|
||
266 if err := s.Set(false); err != nil {
|
||
267 t.Fatal(err)
|
||
268 }
|
||
269 if s.Muted() {
|
||
270 t.Error("nach Set(false): Muted() = true")
|
||
271 }
|
||
272
|
||
273 // Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
|
||
274 if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
|
||
275 t.Fatal(err)
|
||
276 }
|
||
277 if s.Muted() {
|
||
278 t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
|
||
279 }
|
||
280 }
|
||
281
|
||
282 // TestRateLimiter: Struktur-Semantik (Task 05 verdrahtet sie in den Loop).
|
||
283 func TestRateLimiter(t *testing.T) {
|
||
284 // Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
|
||
285 unlimited := &RateLimiter{}
|
||
286 for i := 0; i < 3; i++ {
|
||
287 if !unlimited.Allow() {
|
||
288 t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
|
||
289 }
|
||
290 unlimited.Reserve()
|
||
291 }
|
||
292
|
||
293 // Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
|
||
294 l := &RateLimiter{Interval: 30 * time.Millisecond}
|
||
295 if !l.Allow() {
|
||
296 t.Fatal("frisches Limit: Allow = false")
|
||
297 }
|
||
298 l.Reserve()
|
||
299 if l.Allow() {
|
||
300 t.Fatal("sofort nach Reserve: Allow = true, want false")
|
||
301 }
|
||
302 if next := l.Next(); time.Now().After(next) {
|
||
303 t.Errorf("Next() = %v liegt in der Vergangenheit", next)
|
||
304 }
|
||
305 time.Sleep(40 * time.Millisecond)
|
||
306 if !l.Allow() {
|
||
307 t.Fatal("nach Interval: Allow = false, want true")
|
||
308 }
|
||
309 }
|
||
310
|
||
|
||
internal/queue/rate.go --- Go
|
||
1 package queue
|
||
2
|
||
3 import (
|
||
4 "sync"
|
||
5 "time"
|
||
6 )
|
||
7
|
||
8 // RateLimiter ist die Struktur für das Daemon-Gesetz gegen Ansage-Spam
|
||
9 // (Spec: Default ca. 1 Ansage / 30 s, konfigurierbar).
|
||
10 //
|
||
11 // SEAM FÜR TASK 05: Struktur und Semantik stehen und sind getestet, sind
|
||
12 // aber noch NICHT an den Worker-Loop angebunden — Task 04 erzeugt keinen
|
||
13 // Limiter (unbegrenzt, so verlangt es der Task-04-Brief: „Default unendlich
|
||
14 // in T04-Tests, dokumentiert"). Task 05 verdrahtet den Limiter (config
|
||
15 // queue.rate_limit) in den Loop und ergänzt die prioritätsbewusste
|
||
16 // Entscheidung: low verwerfen, normal parken, high konfigurierbar verstoßen
|
||
17 // (queue.allow_high_burst) — dafür ist Next() bereits Teil der Struktur.
|
||
18 type RateLimiter struct {
|
||
19 // Interval ist der Mindestabstand zwischen zwei Ansage-Starts.
|
||
20 // 0 oder negativ = unbegrenzt (Task-04-Default).
|
||
21 Interval time.Duration
|
||
22
|
||
23 mu sync.Mutex
|
||
24 last time.Time // Start der zuletzt per Reserve gebuchten Ansage
|
||
25 }
|
||
26
|
||
27 // Allow meldet, ob sofort eine Ansage starten dürfte.
|
||
28 func (l *RateLimiter) Allow() bool {
|
||
29 if l == nil || l.Interval <= 0 {
|
||
30 return true
|
||
31 }
|
||
32 l.mu.Lock()
|
||
33 defer l.mu.Unlock()
|
||
34 return time.Since(l.last) >= l.Interval
|
||
35 }
|
||
36
|
||
37 // Reserve bucht einen Ansage-Slot (direkt nach Allow aufrufen).
|
||
38 func (l *RateLimiter) Reserve() {
|
||
39 if l == nil || l.Interval <= 0 {
|
||
40 return
|
||
41 }
|
||
42 l.mu.Lock()
|
||
43 defer l.mu.Unlock()
|
||
44 l.last = time.Now()
|
||
45 }
|
||
46
|
||
47 // Next liefert, wann der nächste Ansage-Slot frei wird (Nullzeit, wenn
|
||
48 // Allow sofort true liefern würde) — Basis für das Parken wartender Items
|
||
49 // in Task 05.
|
||
50 func (l *RateLimiter) Next() time.Time {
|
||
51 if l == nil || l.Interval <= 0 {
|
||
52 return time.Time{}
|
||
53 }
|
||
54 l.mu.Lock()
|
||
55 defer l.mu.Unlock()
|
||
56 return l.last.Add(l.Interval)
|
||
57 }
|
||
58
|
||
|
||
test/cli_test.go --- Go
|
||
1 package test
|
||
2
|
||
3 import (
|
||
4 "os"
|
||
5 "path/filepath"
|
||
6 "strings"
|
||
7 "sync"
|
||
8 "testing"
|
||
9 "time"
|
||
10 )
|
||
11
|
||
12 // TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt
|
||
13 // deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die
|
||
14 // Ansage läuft im Daemon weiter zu Ende.
|
||
15 func TestSayNonBlocking(t *testing.T) {
|
||
16 root := t.TempDir()
|
||
17 fake := newFakeProvider(t, 600*time.Millisecond)
|
||
18 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
19 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
20
|
||
21 start := time.Now()
|
||
22 res := runTalk(t, env, "say", "langlaufende ansage")
|
||
23 elapsed := time.Since(start)
|
||
24
|
||
25 if res.code != 0 {
|
||
26 t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
|
||
27 }
|
||
28 if elapsed >= 500*time.Millisecond {
|
||
29 t.Fatalf("talk say war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
|
||
30 }
|
||
31 id := strings.TrimSpace(res.stdout)
|
||
32 if id == "" || strings.Contains(id, "\n") {
|
||
33 t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout)
|
||
34 }
|
||
35
|
||
36 // Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt).
|
||
37 hits := fake.waitHits(t, 1)
|
||
38 if hits[0].Text != "langlaufende ansage" {
|
||
39 t.Errorf("Treffer = %q", hits[0].Text)
|
||
40 }
|
||
41 waitStatusIdle(t, d, 3*time.Second)
|
||
42 }
|
||
43
|
||
44 // TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück.
|
||
45 func TestSayWait(t *testing.T) {
|
||
46 root := t.TempDir()
|
||
47 fake := newFakeProvider(t, 400*time.Millisecond)
|
||
48 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
49 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
50
|
||
51 start := time.Now()
|
||
52 res := runTalk(t, env, "say", "--wait", "warten auf mich")
|
||
53 elapsed := time.Since(start)
|
||
54
|
||
55 if res.code != 0 {
|
||
56 t.Fatalf("talk say --wait: Exit %d, stderr: %s", res.code, res.stderr)
|
||
57 }
|
||
58 if elapsed < 350*time.Millisecond {
|
||
59 t.Fatalf("talk say --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
|
||
60 }
|
||
61 if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" {
|
||
62 t.Errorf("Treffer = %q", hits[0].Text)
|
||
63 }
|
||
64 waitStatusIdle(t, d, 3*time.Second)
|
||
65 }
|
||
66
|
||
67 // TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
|
||
68 // Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
|
||
69 // gegen den Fake-Provider), Lock danach wieder freigegeben.
|
||
70 func TestSayDegradedMode(t *testing.T) {
|
||
71 root := t.TempDir()
|
||
72 fake := newFakeProvider(t, 0)
|
||
73 env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
|
||
74
|
||
75 res := runTalk(t, env, "say", "offline ansage")
|
||
76 if res.code != 0 {
|
||
77 t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr)
|
||
78 }
|
||
79 if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
|
||
80 t.Errorf("Treffer = %q", hits[0].Text)
|
||
81 }
|
||
82 if !strings.Contains(res.stderr, "Degraded Mode") {
|
||
83 t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
|
||
84 }
|
||
85
|
||
86 // Lock-Datei wurde nach dem Abspielen freigegeben.
|
||
87 lockPath := filepath.Join(configDir(root), "talk.lock")
|
||
88 if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||
89 t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
|
||
90 }
|
||
91 }
|
||
92
|
||
93 // TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
|
||
94 // ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
|
||
95 func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
|
||
96 root := t.TempDir()
|
||
97 fake := newFakeProvider(t, 250*time.Millisecond)
|
||
98 env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||
99
|
||
100 var wg sync.WaitGroup
|
||
101 results := make([]talkResult, 2)
|
||
102 for i, text := range []string{"eins", "zwei"} {
|
||
103 wg.Add(1)
|
||
104 go func(i int, text string) {
|
||
105 defer wg.Done()
|
||
106 results[i] = runTalk(t, env, "say", text)
|
||
107 }(i, text)
|
||
108 }
|
||
109 wg.Wait()
|
||
110
|
||
111 for i, res := range results {
|
||
112 if res.code != 0 {
|
||
113 t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
|
||
114 }
|
||
115 }
|
||
116
|
||
117 // Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
|
||
118 // die zweite Synthese startet erst nach der Antwort der ersten.
|
||
119 hits := fake.waitHits(t, 2)
|
||
120 const slack = 25 * time.Millisecond
|
||
121 if hits[1].Start.Before(hits[0].End.Add(-slack)) {
|
||
122 t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
|
||
123 }
|
||
124 }
|
||
125
|
||
126 // TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
|
||
127 // Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
||
128 func TestSayDegradedRespektiertMute(t *testing.T) {
|
||
129 root := t.TempDir()
|
||
130 fake := newFakeProvider(t, 0)
|
||
131 env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||
132
|
||
133 // Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da").
|
||
134 if err := os.MkdirAll(configDir(root), 0o755); err != nil {
|
||
135 t.Fatal(err)
|
||
136 }
|
||
137 if err := os.WriteFile(filepath.Join(configDir(root), "mute"), []byte("true\n"), 0o644); err != nil {
|
||
138 t.Fatal(err)
|
||
139 }
|
||
140
|
||
141 res := runTalk(t, env, "say", "sollte stumm bleiben")
|
||
142 if res.code != 0 {
|
||
143 t.Fatalf("talk say (mute an): Exit %d, stderr: %s", res.code, res.stderr)
|
||
144 }
|
||
145 if res.stdout != "muted\n" {
|
||
146 t.Errorf("stdout = %q, want muted", res.stdout)
|
||
147 }
|
||
148 fake.assertNoHits(t, 300*time.Millisecond)
|
||
149
|
||
150 // Unmute (offline) → spielt wieder.
|
||
151 res = runTalk(t, env, "unmute")
|
||
152 if res.code != 0 {
|
||
153 t.Fatalf("talk unmute (offline): Exit %d, stderr: %s", res.code, res.stderr)
|
||
154 }
|
||
155 res = runTalk(t, env, "say", "jetzt wieder")
|
||
156 if res.code != 0 {
|
||
157 t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr)
|
||
158 }
|
||
159 if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" {
|
||
160 t.Errorf("Treffer = %q", hits[0].Text)
|
||
161 }
|
||
162 }
|
||
163
|
||
164 // TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit
|
||
165 // plain Output — mute/unmute als Token, status als Block, cancel mit Zahl.
|
||
166 func TestCliMuteUnmuteStatusCancel(t *testing.T) {
|
||
167 root := t.TempDir()
|
||
168 fake := newFakeProvider(t, 0)
|
||
169 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
170 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
171
|
||
172 res := runTalk(t, env, "mute")
|
||
173 if res.code != 0 || res.stdout != "muted\n" {
|
||
174 t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
175 }
|
||
176
|
||
177 // say bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt.
|
||
178 res = runTalk(t, env, "say", "stille")
|
||
179 if res.code != 0 || res.stdout != "muted\n" {
|
||
180 t.Fatalf("talk say (mute an): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
181 }
|
||
182 fake.assertNoHits(t, 300*time.Millisecond)
|
||
183
|
||
184 res = runTalk(t, env, "status")
|
||
185 if res.code != 0 {
|
||
186 t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
|
||
187 }
|
||
188 for _, want := range []string{"version dev", "queue: 0", "mute: an", "provider: elevenlabs"} {
|
||
189 if !strings.Contains(res.stdout, want) {
|
||
190 t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
|
||
191 }
|
||
192 }
|
||
193
|
||
194 res = runTalk(t, env, "unmute")
|
||
195 if res.code != 0 || res.stdout != "unmuted\n" {
|
||
196 t.Fatalf("talk unmute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
197 }
|
||
198
|
||
199 res = runTalk(t, env, "cancel")
|
||
200 if res.code != 0 || res.stdout != "cancelled: 0\n" {
|
||
201 t.Fatalf("talk cancel (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
202 }
|
||
203
|
||
204 res = runTalk(t, env, "status")
|
||
205 if !strings.Contains(res.stdout, "mute: aus") {
|
||
206 t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout)
|
||
207 }
|
||
208 }
|
||
209
|
||
210 // TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende
|
||
211 // Ansage ab und meldet die Zahl.
|
||
212 func TestCliCancelMitQueue(t *testing.T) {
|
||
213 root := t.TempDir()
|
||
214 fake := newFakeProvider(t, 5*time.Second)
|
||
215 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
216 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
217
|
||
218 speak(t, d, `{"text":"blocker"}`)
|
||
219 fake.waitArrived(t, 1)
|
||
220 runTalk(t, env, "say", "wartet auf cancel") // non-blocking enqueue
|
||
221
|
||
222 res := runTalk(t, env, "cancel")
|
||
223 if res.code != 0 || res.stdout != "cancelled: 2\n" {
|
||
224 t.Fatalf("talk cancel: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
225 }
|
||
226
|
||
227 fake.setDelay(50 * time.Millisecond)
|
||
228 waitStatusIdle(t, d, 2*time.Second)
|
||
229 speak(t, d, `{"text":"danach"}`)
|
||
230 hits := fake.waitCompleted(t, 1)
|
||
231 if hits[0].Text != "danach" {
|
||
232 t.Errorf("Treffer = %q, want danach", hits[0].Text)
|
||
233 }
|
||
234 }
|
||
235
|
||
236 // TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` +
|
||
237 // drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue-
|
||
238 // Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle
|
||
239 // drei abgespielt werden und KEINE zwei überlappen.
|
||
240 func TestDreiParalleleTalkAufrufe(t *testing.T) {
|
||
241 root := t.TempDir()
|
||
242 fake := newFakeProvider(t, 200*time.Millisecond)
|
||
243 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
244 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
245
|
||
246 results := make([]talkResult, 3)
|
||
247 var wg sync.WaitGroup
|
||
248 for i, text := range []string{"erste", "zweite", "dritte"} {
|
||
249 wg.Add(1)
|
||
250 go func(i int, text string) {
|
||
251 defer wg.Done()
|
||
252 results[i] = runTalk(t, env, "say", text)
|
||
253 }(i, text)
|
||
254 }
|
||
255 wg.Wait()
|
||
256 for i, res := range results {
|
||
257 if res.code != 0 {
|
||
258 t.Fatalf("paralleler talk-Aufruf %d: Exit %d — stderr: %s", i, res.code, res.stderr)
|
||
259 }
|
||
260 }
|
||
261
|
||
262 hits := fake.waitCompleted(t, 3)
|
||
263 played := map[string]bool{}
|
||
264 for _, h := range hits {
|
||
265 played[h.Text] = true
|
||
266 }
|
||
267 for _, text := range []string{"erste", "zweite", "dritte"} {
|
||
268 if !played[text] {
|
||
269 t.Errorf("Ansage %q fehlt (Treffer: %+v)", text, hits)
|
||
270 }
|
||
271 }
|
||
272 const slack = 25 * time.Millisecond
|
||
273 for i := 1; i < len(hits); i++ {
|
||
274 if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
|
||
275 t.Errorf("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End)
|
||
276 }
|
||
277 }
|
||
278 waitStatusIdle(t, d, 3*time.Second)
|
||
279 }
|
||
280
|
||
281 // TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
|
||
282 // Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode.
|
||
283 func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
|
||
284 root := t.TempDir()
|
||
285 fake := newFakeProvider(t, 0)
|
||
286 env := daemonEnv(root, 1, fake.srv.URL, nil)
|
||
287
|
||
288 if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
|
||
289 t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
|
||
290 }
|
||
291 if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
|
||
292 t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
|
||
293 }
|
||
294 }
|
||
295
|
||
296 // TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie
|
||
297 // `talk say "Text"` (Flags vor und nach dem Text).
|
||
298 func TestSayDefaultPfadWieSay(t *testing.T) {
|
||
299 root := t.TempDir()
|
||
300 fake := newFakeProvider(t, 0)
|
||
301 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
302 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
303
|
||
304 res := runTalk(t, env, "default aufruf")
|
||
305 if res.code != 0 {
|
||
306 t.Fatalf("talk \"Text\": Exit %d — stderr: %s", res.code, res.stderr)
|
||
307 }
|
||
308 if id := strings.TrimSpace(res.stdout); id == "" {
|
||
309 t.Errorf("stdout = %q, will Item-ID", res.stdout)
|
||
310 }
|
||
311
|
||
312 // Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad.
|
||
313 res = runTalk(t, env, "noch ein text", "--lang=en")
|
||
314 if res.code != 0 {
|
||
315 t.Fatalf("talk Text --lang=en: Exit %d — stderr: %s", res.code, res.stderr)
|
||
316 }
|
||
317
|
||
318 hits := fake.waitHits(t, 2)
|
||
319 if !strings.Contains(hits[1].Text, "noch ein text") {
|
||
320 t.Errorf("zweiter Treffer = %q", hits[1].Text)
|
||
321 }
|
||
322 waitStatusIdle(t, d, 3*time.Second)
|
||
323 }
|
||
324
|
||
|
||
test/harness_test.go --- Go
|
||
1 // Package test enthält die Prozessgrenzen-Tests von talk 2.0 — die primäre
|
||
2 // Test-Seam laut Spec: talkd und talk laufen als echte Prozesse (gebuildet
|
||
3 // aus diesem Repo), angetrieben über HTTP und die CLI, mit zwei injizierten
|
||
4 // Fakes: einem ElevenLabs-Fake-Endpunkt (httptest-Server mit Audio-Bytes)
|
||
5 // und audio.sink=null. Getestet wird nur extern beobachtbares Verhalten:
|
||
6 // HTTP-Antworten, CLI-Exit-Codes/-Output, Fake-Treffer (Reihenfolge und
|
||
7 // Zeit), Dateien im Config-Dir. Tests binden NIEMALS den Default-Port 7800
|
||
8 // und hängen nie von einem echten Daemon ab — talkd bekommt einen freien
|
||
9 // Port per TALK_PORT, CLI-Prozesse denselben (oder Port 1 für „Daemon tot").
|
||
10 package test
|
||
11
|
||
12 import (
|
||
13 "bytes"
|
||
14 "encoding/json"
|
||
15 "fmt"
|
||
16 "net"
|
||
17 "net/http"
|
||
18 "net/http/httptest"
|
||
19 "os"
|
||
20 "os/exec"
|
||
21 "path/filepath"
|
||
22 "strconv"
|
||
23 "strings"
|
||
24 "sync"
|
||
25 "testing"
|
||
26 "time"
|
||
27 )
|
||
28
|
||
29 // Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
|
||
30 // billig) und von allen Tests geteilt.
|
||
31 var (
|
||
32 talkBin string
|
||
33 talkdBin string
|
||
34 )
|
||
35
|
||
36 func TestMain(m *testing.M) {
|
||
37 dir, err := os.MkdirTemp("", "talk-bins-*")
|
||
38 if err != nil {
|
||
39 fmt.Fprintln(os.Stderr, err)
|
||
40 os.Exit(1)
|
||
41 }
|
||
42 repo, err := filepath.Abs("..")
|
||
43 if err != nil {
|
||
44 fmt.Fprintln(os.Stderr, err)
|
||
45 os.Exit(1)
|
||
46 }
|
||
47 for _, b := range []struct{ out, pkg string }{
|
||
48 {"talk", "./cmd/talk"},
|
||
49 {"talkd", "./cmd/talkd"},
|
||
50 } {
|
||
51 cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg)
|
||
52 cmd.Dir = repo
|
||
53 if out, err := cmd.CombinedOutput(); err != nil {
|
||
54 fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out)
|
||
55 os.RemoveAll(dir)
|
||
56 os.Exit(1)
|
||
57 }
|
||
58 }
|
||
59 talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
|
||
60
|
||
61 code := m.Run()
|
||
62 os.RemoveAll(dir)
|
||
63 os.Exit(code)
|
||
64 }
|
||
65
|
||
66 // syncBuffer ist ein nebenläufigkeitssicherer Buffer (Daemon-stderr wird
|
||
67 // gelesen, während der Prozess läuft).
|
||
68 type syncBuffer struct {
|
||
69 mu sync.Mutex
|
||
70 buf bytes.Buffer
|
||
71 }
|
||
72
|
||
73 func (b *syncBuffer) Write(p []byte) (int, error) {
|
||
74 b.mu.Lock()
|
||
75 defer b.mu.Unlock()
|
||
76 return b.buf.Write(p)
|
||
77 }
|
||
78
|
||
79 func (b *syncBuffer) String() string {
|
||
80 b.mu.Lock()
|
||
81 defer b.mu.Unlock()
|
||
82 return b.buf.String()
|
||
83 }
|
||
84
|
||
85 // fakeHit ist ein beobachteter Synthese-Request am Fake-Provider.
|
||
86 type fakeHit struct {
|
||
87 Text string // Text aus dem Request-Body
|
||
88 Start time.Time // Ankunft am Fake
|
||
89 End time.Time // Antwort abgeschickt bzw. Abbruch gesehen
|
||
90 Completed bool // true: Antwort geschrieben; false: Client hat abgebrochen
|
||
91 }
|
||
92
|
||
93 // fakeProvider ist ein ElevenLabs-Fake (POST /v1/text-to-speech/<voice>):
|
||
94 // Er antwortet mit ein paar Bytes „Audio" und protokolliert jeden Request
|
||
95 // mit Ankunfts- und Antwort-Zeit — daraus sind Reihenfolge (Textfolge) und
|
||
96 // Serialisierung (Ankunft des nächsten nach Antwort des vorigen) ablesbar.
|
||
97 // delay ist zur Laufzeit umschaltbar, um blockierende Synthesen zu
|
||
98 // simulieren (Serialisierung, Priorität, cancel). Ankünfte werden separat
|
||
99 // und sofort protokolliert: Sie zeigen, dass der Daemon mit einer Synthese
|
||
100 // BESCHÄFTIGT ist (Queue deterministisch befüllbar), noch bevor die
|
||
101 // (verzögerte) Antwort als Treffer landet.
|
||
102 type fakeProvider struct {
|
||
103 srv *httptest.Server
|
||
104 mu sync.Mutex
|
||
105 hits []fakeHit
|
||
106 arrived []string
|
||
107 delay time.Duration
|
||
108 }
|
||
109
|
||
110 func newFakeProvider(t *testing.T, delay time.Duration) *fakeProvider {
|
||
111 t.Helper()
|
||
112 f := &fakeProvider{delay: delay}
|
||
113 f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
||
114 t.Cleanup(f.srv.Close)
|
||
115 return f
|
||
116 }
|
||
117
|
||
118 func (f *fakeProvider) serve(w http.ResponseWriter, r *http.Request) {
|
||
119 var body struct {
|
||
120 Text string `json:"text"`
|
||
121 }
|
||
122 start := time.Now()
|
||
123 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
124 http.Error(w, err.Error(), http.StatusBadRequest)
|
||
125 return
|
||
126 }
|
||
127 f.mu.Lock()
|
||
128 f.arrived = append(f.arrived, body.Text)
|
||
129 d := f.delay
|
||
130 f.mu.Unlock()
|
||
131 if d > 0 {
|
||
132 select {
|
||
133 case <-time.After(d):
|
||
134 case <-r.Context().Done(): // Client (Daemon) hat abgebrochen
|
||
135 f.record(fakeHit{Text: body.Text, Start: start, End: time.Now()})
|
||
136 return
|
||
137 }
|
||
138 }
|
||
139 _, _ = w.Write([]byte("\xff\xfb\x90\x00 fake-mp3-bytes"))
|
||
140 f.record(fakeHit{Text: body.Text, Start: start, End: time.Now(), Completed: true})
|
||
141 }
|
||
142
|
||
143 func (f *fakeProvider) record(h fakeHit) {
|
||
144 f.mu.Lock()
|
||
145 defer f.mu.Unlock()
|
||
146 f.hits = append(f.hits, h)
|
||
147 }
|
||
148
|
||
149 func (f *fakeProvider) setDelay(d time.Duration) {
|
||
150 f.mu.Lock()
|
||
151 f.delay = d
|
||
152 f.mu.Unlock()
|
||
153 }
|
||
154
|
||
155 func (f *fakeProvider) snapshot() []fakeHit {
|
||
156 f.mu.Lock()
|
||
157 defer f.mu.Unlock()
|
||
158 out := make([]fakeHit, len(f.hits))
|
||
159 copy(out, f.hits)
|
||
160 return out
|
||
161 }
|
||
162
|
||
163 // completed liefert nur die abgeschlossenen (nicht abgebrochenen) Treffer.
|
||
164 func completed(hits []fakeHit) []fakeHit {
|
||
165 var out []fakeHit
|
||
166 for _, h := range hits {
|
||
167 if h.Completed {
|
||
168 out = append(out, h)
|
||
169 }
|
||
170 }
|
||
171 return out
|
||
172 }
|
||
173
|
||
174 // waitArrived pollt, bis n Synthese-Requests angekommen sind — der Daemon
|
||
175 // ist damit nachweisbar beschäftigt und die Queue deterministisch befüllbar.
|
||
176 func (f *fakeProvider) waitArrived(t *testing.T, n int) {
|
||
177 t.Helper()
|
||
178 deadline := time.Now().Add(5 * time.Second)
|
||
179 for time.Now().Before(deadline) {
|
||
180 f.mu.Lock()
|
||
181 count := len(f.arrived)
|
||
182 f.mu.Unlock()
|
||
183 if count >= n {
|
||
184 return
|
||
185 }
|
||
186 time.Sleep(10 * time.Millisecond)
|
||
187 }
|
||
188 t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
|
||
189 }
|
||
190
|
||
191 // waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
|
||
192 func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
|
||
193 t.Helper()
|
||
194 deadline := time.Now().Add(5 * time.Second)
|
||
195 for time.Now().Before(deadline) {
|
||
196 if hits := f.snapshot(); len(hits) >= n {
|
||
197 return hits
|
||
198 }
|
||
199 time.Sleep(20 * time.Millisecond)
|
||
200 }
|
||
201 t.Fatalf("nur %d von %d Fake-Treffern innerhalb 5 s (Treffer: %+v)", len(f.snapshot()), n, f.snapshot())
|
||
202 return nil
|
||
203 }
|
||
204
|
||
205 // waitCompleted pollt, bis n Requests ERFOLGREICH abgeschlossen wurden
|
||
206 // (abgebrochene Synthesen zählen nicht).
|
||
207 func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
|
||
208 t.Helper()
|
||
209 deadline := time.Now().Add(5 * time.Second)
|
||
210 for time.Now().Before(deadline) {
|
||
211 if hits := completed(f.snapshot()); len(hits) >= n {
|
||
212 return hits
|
||
213 }
|
||
214 time.Sleep(20 * time.Millisecond)
|
||
215 }
|
||
216 t.Fatalf("nur %d von %d abgeschlossenen Treffern innerhalb 5 s (alle: %+v)", len(completed(f.snapshot())), n, f.snapshot())
|
||
217 return nil
|
||
218 }
|
||
219
|
||
220 // assertNoHits prüft, dass innerhalb der Frist kein Request ankam.
|
||
221 func (f *fakeProvider) assertNoHits(t *testing.T, wait time.Duration) {
|
||
222 t.Helper()
|
||
223 time.Sleep(wait)
|
||
224 if hits := f.snapshot(); len(hits) > 0 {
|
||
225 t.Fatalf("unerwartete Fake-Treffer: %+v", hits)
|
||
226 }
|
||
227 }
|
||
228
|
||
229 // daemonProc ist ein laufender talkd-Prozess.
|
||
230 type daemonProc struct {
|
||
231 cmd *exec.Cmd
|
||
232 port int
|
||
233 stderr *syncBuffer
|
||
234 baseURL string
|
||
235 }
|
||
236
|
||
237 // startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT,
|
||
238 // nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
|
||
239 // t.Cleanup: SIGINT, nach Frist Kill.
|
||
240 func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
|
||
241 t.Helper()
|
||
242 port := freePort(t)
|
||
243 env := daemonEnv(root, port, fakeURL, extra)
|
||
244 stderr := &syncBuffer{}
|
||
245 cmd := exec.Command(talkdBin)
|
||
246 cmd.Env = env
|
||
247 cmd.Stderr = stderr
|
||
248 if err := cmd.Start(); err != nil {
|
||
249 t.Fatalf("talkd starten: %v", err)
|
||
250 }
|
||
251 d := &daemonProc{
|
||
252 cmd: cmd,
|
||
253 port: port,
|
||
254 stderr: stderr,
|
||
255 baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||
256 }
|
||
257
|
||
258 client := &http.Client{Timeout: time.Second}
|
||
259 deadline := time.Now().Add(5 * time.Second)
|
||
260 for time.Now().Before(deadline) {
|
||
261 resp, err := client.Get(d.baseURL + "/status")
|
||
262 if err == nil {
|
||
263 resp.Body.Close()
|
||
264 if resp.StatusCode == http.StatusOK {
|
||
265 t.Cleanup(func() { d.stop(t) })
|
||
266 return d
|
||
267 }
|
||
268 }
|
||
269 time.Sleep(25 * time.Millisecond)
|
||
270 }
|
||
271 t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
|
||
272 return nil
|
||
273 }
|
||
274
|
||
275 // stop fährt den Daemon-Prozess herunter (SIGINT wie ein menschlicher
|
||
276 // Stop; nach 3 s Kill-Fallback) und wartet auf sein Ende.
|
||
277 func (d *daemonProc) stop(t *testing.T) {
|
||
278 t.Helper()
|
||
279 if d.cmd.Process == nil {
|
||
280 return
|
||
281 }
|
||
282 done := make(chan error, 1)
|
||
283 go func() { done <- d.cmd.Wait() }()
|
||
284 _ = d.cmd.Process.Signal(os.Interrupt)
|
||
285 select {
|
||
286 case <-done:
|
||
287 case <-time.After(3 * time.Second):
|
||
288 _ = d.cmd.Process.Kill()
|
||
289 <-done
|
||
290 }
|
||
291 }
|
||
292
|
||
293 // daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse:
|
||
294 // geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
|
||
295 // Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
|
||
296 // damit unter <root>/talk), audio.sink=null und der Fake-Provider als
|
||
297 // ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
|
||
298 // wird sofort abgelehnt).
|
||
299 func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
|
||
300 env := make([]string, 0, 32)
|
||
301 for _, kv := range os.Environ() {
|
||
302 if strings.HasPrefix(kv, "TALK_") {
|
||
303 continue
|
||
304 }
|
||
305 env = append(env, kv)
|
||
306 }
|
||
307 set := map[string]string{
|
||
308 "HOME": root,
|
||
309 "XDG_CONFIG_HOME": root,
|
||
310 "TALK_AUDIO_SINK": "null",
|
||
311 "TALK_ELEVENLABS_KEY": "prozesstest-key",
|
||
312 "TALK_ELEVENLABS_ENDPOINT": fakeURL,
|
||
313 "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
|
||
314 "TALK_PORT": strconv.Itoa(port),
|
||
315 }
|
||
316 for k, v := range extra {
|
||
317 set[k] = v
|
||
318 }
|
||
319 for k, v := range set {
|
||
320 env = append(env, k+"="+v)
|
||
321 }
|
||
322 return env
|
||
323 }
|
||
324
|
||
325 // freePort besorgt einen freien Loopback-Port (bind-and-close; der Daemon
|
||
326 // bindet ihn sofort danach neu).
|
||
327 func freePort(t *testing.T) int {
|
||
328 t.Helper()
|
||
329 ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||
330 if err != nil {
|
||
331 t.Fatalf("freien Port finden: %v", err)
|
||
332 }
|
||
333 defer ln.Close()
|
||
334 return ln.Addr().(*net.TCPAddr).Port
|
||
335 }
|
||
336
|
||
337 // configDir ist das Config-Verzeichnis unter dem isolierten Root
|
||
338 // (Linux-Pfadlogik: XDG_CONFIG_HOME/<root> → <root>/talk).
|
||
339 func configDir(root string) string {
|
||
340 return filepath.Join(root, "talk")
|
||
341 }
|
||
342
|
||
343 // talkResult ist das Beobachtungsergebnis eines talk-Prozesses.
|
||
344 type talkResult struct {
|
||
345 code int
|
||
346 stdout string
|
||
347 stderr string
|
||
348 }
|
||
349
|
||
350 // runTalk führt die talk-CLI als echten Prozess aus.
|
||
351 func runTalk(t *testing.T, env []string, args ...string) talkResult {
|
||
352 t.Helper()
|
||
353 cmd := exec.Command(talkBin, args...)
|
||
354 cmd.Env = env
|
||
355 var out, errBuf syncBuffer
|
||
356 cmd.Stdout = &out
|
||
357 cmd.Stderr = &errBuf
|
||
358 err := cmd.Run()
|
||
359 code := 0
|
||
360 if ee, ok := err.(*exec.ExitError); ok {
|
||
361 code = ee.ExitCode()
|
||
362 } else if err != nil {
|
||
363 t.Fatalf("talk ausführen: %v (stderr: %s)", err, errBuf.String())
|
||
364 }
|
||
365 return talkResult{code: code, stdout: out.String(), stderr: errBuf.String()}
|
||
366 }
|
||
367
|
||
368 // statusBody ist die dekodierte GET /status-Antwort.
|
||
369 type statusBody struct {
|
||
370 Version string `json:"version"`
|
||
371 Queue int `json:"queue"`
|
||
372 QueueIDs []string `json:"queue_ids"`
|
||
373 Current *struct {
|
||
374 ID string `json:"id"`
|
||
375 Text string `json:"text"`
|
||
376 Priority string `json:"priority"`
|
||
377 } `json:"current"`
|
||
378 Muted bool `json:"muted"`
|
||
379 Provider string `json:"provider"`
|
||
380 }
|
||
381
|
||
382 // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist
|
||
383 // lokal und muss antworten, sonst ist das selbst ein Testergebnis).
|
||
384 func getStatus(t *testing.T, d *daemonProc) statusBody {
|
||
385 t.Helper()
|
||
386 client := &http.Client{Timeout: time.Second}
|
||
387 resp, err := client.Get(d.baseURL + "/status")
|
||
388 if err != nil {
|
||
389 t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String())
|
||
390 }
|
||
391 defer resp.Body.Close()
|
||
392 if resp.StatusCode != http.StatusOK {
|
||
393 t.Fatalf("GET /status: Status %d", resp.StatusCode)
|
||
394 }
|
||
395 var st statusBody
|
||
396 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
||
397 t.Fatalf("GET /status dekodieren: %v", err)
|
||
398 }
|
||
399 return st
|
||
400 }
|
||
401
|
||
402 // waitStatusIdle pollt, bis weder ein Item läuft noch wartet.
|
||
403 func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
|
||
404 t.Helper()
|
||
405 deadline := time.Now().Add(timeout)
|
||
406 for {
|
||
407 st := getStatus(t, d)
|
||
408 if st.Current == nil && st.Queue == 0 {
|
||
409 return
|
||
410 }
|
||
411 if time.Now().After(deadline) {
|
||
412 t.Fatalf("Queue wird nicht idle: %+v — stderr:\n%s", st, d.stderr.String())
|
||
413 }
|
||
414 time.Sleep(25 * time.Millisecond)
|
||
415 }
|
||
416 }
|
||
417
|
||
418 // speak enqueue per rohem HTTP (Prozessgrenze ohne CLI).
|
||
419 func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
|
||
420 t.Helper()
|
||
421 resp, err := http.Post(d.baseURL+"/speak", "application/json", strings.NewReader(body))
|
||
422 if err != nil {
|
||
423 t.Fatalf("POST /speak: %v", err)
|
||
424 }
|
||
425 defer resp.Body.Close()
|
||
426 var m map[string]any
|
||
427 if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||
428 t.Fatalf("POST /speak Antwort dekodieren: %v", err)
|
||
429 }
|
||
430 return resp.StatusCode, m
|
||
431 }
|
||
432
|
||
433 // postPath macht einen leeren POST auf einen Daemon-Endpunkt (/mute,
|
||
434 // /unmute, /cancel) und dekodiert die JSON-Antwort.
|
||
435 func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
|
||
436 t.Helper()
|
||
437 resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader("{}"))
|
||
438 if err != nil {
|
||
439 t.Fatalf("POST %s: %v", path, err)
|
||
440 }
|
||
441 defer resp.Body.Close()
|
||
442 var m map[string]any
|
||
443 if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||
444 t.Fatalf("POST %s Antwort dekodieren: %v", path, err)
|
||
445 }
|
||
446 return resp.StatusCode, m
|
||
447 }
|
||
448
|
||
|
||
test/mute_test.go --- Go
|
||
1 package test
|
||
2
|
||
3 import (
|
||
4 "os"
|
||
5 "path/filepath"
|
||
6 "strings"
|
||
7 "testing"
|
||
8 "time"
|
||
9 )
|
||
10
|
||
11 // TestMutePersistenzUeberRestart (Abnahmekriterium): `talk mute` → Daemon-
|
||
12 // Restart → weiter stumm. Mute verwirft Ansagen dokumentiert mit
|
||
13 // 202 {id:"", muted:true} und ohne jeden Provider-Kontakt.
|
||
14 func TestMutePersistenzUeberRestart(t *testing.T) {
|
||
15 root := t.TempDir()
|
||
16 fake := newFakeProvider(t, 0)
|
||
17 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
18
|
||
19 // Über die HTTP-API stummschalten: Datei mute liegt im Config-Dir.
|
||
20 code, m := postPath(t, d, "/mute")
|
||
21 if code != 200 || m["muted"] != true {
|
||
22 t.Fatalf("POST /mute: %d %v", code, m)
|
||
23 }
|
||
24 muteFile := filepath.Join(configDir(root), "mute")
|
||
25 b, err := os.ReadFile(muteFile)
|
||
26 if err != nil {
|
||
27 t.Fatalf("Mute-Datei fehlt im Config-Dir: %v", err)
|
||
28 }
|
||
29 if strings.TrimSpace(string(b)) != "true" {
|
||
30 t.Errorf("Mute-Datei = %q, want true", b)
|
||
31 }
|
||
32
|
||
33 // Ansagen werden verworfen: 202, muted:true, leere ID, kein Treffer.
|
||
34 code, m = speak(t, d, `{"text":"stille"}`)
|
||
35 if code != 202 || m["muted"] != true || m["id"] != "" {
|
||
36 t.Fatalf("speak bei Mute: %d %v, want 202 {id:, muted:true}", code, m)
|
||
37 }
|
||
38 fake.assertNoHits(t, 300*time.Millisecond)
|
||
39 if st := getStatus(t, d); !st.Muted {
|
||
40 t.Fatal("/status meldet muted=false")
|
||
41 }
|
||
42
|
||
43 // Daemon-Restart mit demselben Config-Dir: weiter stumm.
|
||
44 d.stop(t)
|
||
45 d2 := startDaemon(t, root, fake.srv.URL, nil)
|
||
46
|
||
47 if st := getStatus(t, d2); !st.Muted {
|
||
48 t.Fatalf("nach Restart muted = false, want true (persistent)")
|
||
49 }
|
||
50 code, m = speak(t, d2, `{"text":"immer noch stumm"}`)
|
||
51 if code != 202 || m["muted"] != true {
|
||
52 t.Fatalf("speak nach Restart bei Mute: %d %v", code, m)
|
||
53 }
|
||
54 fake.assertNoHits(t, 300*time.Millisecond)
|
||
55
|
||
56 // Unmute: Ansagen laufen wieder.
|
||
57 code, m = postPath(t, d2, "/unmute")
|
||
58 if code != 200 || m["muted"] != false {
|
||
59 t.Fatalf("POST /unmute: %d %v", code, m)
|
||
60 }
|
||
61 code, m = speak(t, d2, `{"text":"wieder da"}`)
|
||
62 if code != 202 || m["id"] == "" {
|
||
63 t.Fatalf("speak nach unmute: %d %v, want 202 mit id", code, m)
|
||
64 }
|
||
65 hits := fake.waitHits(t, 1)
|
||
66 if hits[0].Text != "wieder da" {
|
||
67 t.Errorf("Treffer = %q, want wieder da", hits[0].Text)
|
||
68 }
|
||
69 }
|
||
70
|
||
71 // TestDegradedMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
|
||
72 // Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
|
||
73 // dieselbe Quelle.
|
||
74 func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) {
|
||
75 root := t.TempDir()
|
||
76 fake := newFakeProvider(t, 0)
|
||
77 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
78 deadEnv := daemonEnv(root, 1, fake.srv.URL, nil)
|
||
79
|
||
80 // Offline stummschalten (talk mute ohne Daemon → Datei direkt).
|
||
81 res := runTalk(t, deadEnv, "mute")
|
||
82 if res.code != 0 || res.stdout != "muted\n" {
|
||
83 t.Fatalf("talk mute (offline): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
||
84 }
|
||
85
|
||
86 // Der laufende Daemon sieht den Zustand bei der nächsten Ansage.
|
||
87 if st := getStatus(t, d); !st.Muted {
|
||
88 t.Fatal("Daemon sieht offline geschriebene Mute-Datei nicht")
|
||
89 }
|
||
90 code, m := speak(t, d, `{"text":"soll verworfen werden"}`)
|
||
91 if code != 202 || m["muted"] != true {
|
||
92 t.Fatalf("speak: %d %v, want verworfen", code, m)
|
||
93 }
|
||
94 fake.assertNoHits(t, 300*time.Millisecond)
|
||
95
|
||
96 res = runTalk(t, deadEnv, "unmute")
|
||
97 if res.code != 0 {
|
||
98 t.Fatalf("talk unmute (offline): %d — stderr: %s", res.code, res.stderr)
|
||
99 }
|
||
100 if st := getStatus(t, d); st.Muted {
|
||
101 t.Fatal("Daemon sieht offline geschriebene Unmute-Datei nicht")
|
||
102 }
|
||
103 }
|
||
104
|
||
|
||
test/queue_test.go --- Go
|
||
1 package test
|
||
2
|
||
3 import (
|
||
4 "fmt"
|
||
5 "net/http"
|
||
6 "strings"
|
||
7 "testing"
|
||
8 "time"
|
||
9 )
|
||
10
|
||
11 // TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen
|
||
12 // spielen nacheinander — die zweite Synthese erreicht den Fake-Provider
|
||
13 // erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge
|
||
14 // bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar.
|
||
15 func TestSerialisierung(t *testing.T) {
|
||
16 root := t.TempDir()
|
||
17 fake := newFakeProvider(t, 250*time.Millisecond)
|
||
18 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
19
|
||
20 code, first := speak(t, d, `{"text":"erste ansage"}`)
|
||
21 if code != 202 || first["id"] == "" || first["muted"] == true {
|
||
22 t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first)
|
||
23 }
|
||
24
|
||
25 // Sobald die erste Synthese beim Fake angekommen ist (Daemon
|
||
26 // beschäftigt), zwei weitere schnell hinterher — sie müssen sich
|
||
27 // hinter der laufenden einreihen.
|
||
28 fake.waitArrived(t, 1)
|
||
29 speak(t, d, `{"text":"zweite ansage"}`)
|
||
30 speak(t, d, `{"text":"dritte ansage"}`)
|
||
31
|
||
32 hits := fake.waitHits(t, 3)
|
||
33
|
||
34 var texts []string
|
||
35 for _, h := range hits {
|
||
36 texts = append(texts, h.Text)
|
||
37 }
|
||
38 if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" {
|
||
39 t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts)
|
||
40 }
|
||
41 // Serialisierung: die nächste Synthese startet erst nach der Antwort
|
||
42 // der vorherigen (kleine Toleranz für Uhr-Jitter).
|
||
43 const slack = 25 * time.Millisecond
|
||
44 for i := 1; i < len(hits); i++ {
|
||
45 if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
|
||
46 t.Errorf("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End)
|
||
47 }
|
||
48 }
|
||
49
|
||
50 // Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig
|
||
51 // erst kurz NACH der beim Fake aufgezeichneten Antwort.
|
||
52 waitStatusIdle(t, d, 3*time.Second)
|
||
53
|
||
54 // Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar).
|
||
55 if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 {
|
||
56 t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
|
||
57 }
|
||
58 }
|
||
59
|
||
60 // TestPrioritaet: während einer langen Ansage werden low/normal/high
|
||
61 // eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level).
|
||
62 func TestPrioritaet(t *testing.T) {
|
||
63 root := t.TempDir()
|
||
64 fake := newFakeProvider(t, 200*time.Millisecond)
|
||
65 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
66
|
||
67 speak(t, d, `{"text":"blocker"}`)
|
||
68 fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden.
|
||
69
|
||
70 // Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet
|
||
71 // IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen.
|
||
72 enq := make(map[string]string)
|
||
73 for _, tc := range []struct{ body, text string }{
|
||
74 {`{"text":"niedrig","priority":"low"}`, "niedrig"},
|
||
75 {`{"text":"normal-eins"}`, "normal-eins"},
|
||
76 {`{"text":"normal-zwei"}`, "normal-zwei"},
|
||
77 {`{"text":"dringend","priority":"high"}`, "dringend"},
|
||
78 } {
|
||
79 code, m := speak(t, d, tc.body)
|
||
80 if code != 202 {
|
||
81 t.Fatalf("speak %s: Status %d", tc.body, code)
|
||
82 }
|
||
83 id, _ := m["id"].(string)
|
||
84 if id == "" {
|
||
85 t.Fatalf("speak %s: keine ID in %v", tc.body, m)
|
||
86 }
|
||
87 enq[tc.text] = id
|
||
88 }
|
||
89 wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"}
|
||
90
|
||
91 // Status zeigt aktuelles Item und die wartenden in Abarbeitungs-
|
||
92 // Reihenfolge (deterministisch: der Blocker hält den Worker).
|
||
93 st := getStatus(t, d)
|
||
94 if st.Current == nil || st.Current.Text != "blocker" {
|
||
95 t.Fatalf("current = %+v, will blocker", st.Current)
|
||
96 }
|
||
97 if st.Queue != 4 {
|
||
98 t.Fatalf("Queue-Länge = %d, want 4", st.Queue)
|
||
99 }
|
||
100 var wantIDs []string
|
||
101 for _, text := range wantOrder {
|
||
102 wantIDs = append(wantIDs, enq[text])
|
||
103 }
|
||
104 if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
|
||
105 t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs)
|
||
106 }
|
||
107
|
||
108 hits := fake.waitHits(t, 5)
|
||
109 var texts []string
|
||
110 for _, h := range hits {
|
||
111 texts = append(texts, h.Text)
|
||
112 }
|
||
113 want := append([]string{"blocker"}, wantOrder...)
|
||
114 if strings.Join(texts, "|") != strings.Join(want, "|") {
|
||
115 t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want)
|
||
116 }
|
||
117 waitStatusIdle(t, d, 3*time.Second)
|
||
118 }
|
||
119
|
||
120 // TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage
|
||
121 // springt vor alle wartenden normal-Items (unterbricht aber nicht den
|
||
122 // laufenden Blocker).
|
||
123 func TestNowSpringtVor(t *testing.T) {
|
||
124 root := t.TempDir()
|
||
125 fake := newFakeProvider(t, 600*time.Millisecond)
|
||
126 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
127 env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
||
128
|
||
129 speak(t, d, `{"text":"blocker"}`)
|
||
130 fake.waitArrived(t, 1)
|
||
131
|
||
132 speak(t, d, `{"text":"spaeter"}`)
|
||
133
|
||
134 res := runTalk(t, env, "say", "--now", "sofort")
|
||
135 if res.code != 0 {
|
||
136 t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr)
|
||
137 }
|
||
138 if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
|
||
139 t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout)
|
||
140 }
|
||
141
|
||
142 hits := fake.waitHits(t, 3)
|
||
143 var texts []string
|
||
144 for _, h := range hits {
|
||
145 texts = append(texts, h.Text)
|
||
146 }
|
||
147 if strings.Join(texts, "|") != "blocker|sofort|spaeter" {
|
||
148 t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts)
|
||
149 }
|
||
150 waitStatusIdle(t, d, 3*time.Second)
|
||
151 }
|
||
152
|
||
153 // TestCancel: bricht die laufende Ansage ab (Synthese-Request wird
|
||
154 // abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon
|
||
155 // bleibt danach funktionstüchtig.
|
||
156 func TestCancel(t *testing.T) {
|
||
157 root := t.TempDir()
|
||
158 fake := newFakeProvider(t, 5*time.Second)
|
||
159 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
160
|
||
161 code, first := speak(t, d, `{"text":"blocker"}`)
|
||
162 if code != 202 {
|
||
163 t.Fatalf("speak: Status %d", code)
|
||
164 }
|
||
165 fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay).
|
||
166
|
||
167 code, second := speak(t, d, `{"text":"wartet"}`)
|
||
168 if code != 202 {
|
||
169 t.Fatalf("speak: Status %d", code)
|
||
170 }
|
||
171
|
||
172 // Status-Vertrag: aktuelles Item + ein wartendes.
|
||
173 st := getStatus(t, d)
|
||
174 if st.Current == nil || st.Current.ID != first["id"] {
|
||
175 t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"])
|
||
176 }
|
||
177 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
|
||
178 t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue)
|
||
179 }
|
||
180
|
||
181 code, m := postPath(t, d, "/cancel")
|
||
182 if code != 200 {
|
||
183 t.Fatalf("POST /cancel: Status %d", code)
|
||
184 }
|
||
185 if m["cancelled"] != float64(2) {
|
||
186 t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"])
|
||
187 }
|
||
188
|
||
189 // Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue
|
||
190 // Ansagen an — cancel beschädigt den Daemon nicht.
|
||
191 waitStatusIdle(t, d, 2*time.Second)
|
||
192 fake.setDelay(50 * time.Millisecond)
|
||
193 speak(t, d, `{"text":"nachher"}`)
|
||
194 hits := fake.waitCompleted(t, 1)
|
||
195 if hits[0].Text != "nachher" {
|
||
196 t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text)
|
||
197 }
|
||
198 waitStatusIdle(t, d, 3*time.Second)
|
||
199 }
|
||
200
|
||
201 // TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400
|
||
202 // ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen.
|
||
203 func TestSpeakValidierung(t *testing.T) {
|
||
204 root := t.TempDir()
|
||
205 fake := newFakeProvider(t, 0)
|
||
206 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
207
|
||
208 cases := []struct {
|
||
209 name string
|
||
210 body string
|
||
211 }{
|
||
212 {"kein JSON", `kein json`},
|
||
213 {"text fehlt", `{}`},
|
||
214 {"text leer", `{"text":" "}`},
|
||
215 {"priority Müll", `{"text":"x","priority":"sofort"}`},
|
||
216 {"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`},
|
||
217 {"lang Müll", `{"text":"x","lang":"fr"}`},
|
||
218 }
|
||
219 for _, tc := range cases {
|
||
220 t.Run(tc.name, func(t *testing.T) {
|
||
221 code, m := speak(t, d, tc.body)
|
||
222 if code != 400 {
|
||
223 t.Fatalf("Status = %d, want 400 (Body: %v)", code, m)
|
||
224 }
|
||
225 if _, ok := m["error"]; !ok {
|
||
226 t.Errorf("Antwort ohne error-Feld: %v", m)
|
||
227 }
|
||
228 })
|
||
229 }
|
||
230
|
||
231 // Falsche Methode → 405 (Methoden-gebundene Routen).
|
||
232 resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
|
||
233 if err != nil {
|
||
234 t.Fatal(err)
|
||
235 }
|
||
236 resp.Body.Close()
|
||
237 if resp.StatusCode != http.StatusMethodNotAllowed {
|
||
238 t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode)
|
||
239 }
|
||
240
|
||
241 // Danach ist der Daemon weiterhin gesund und speakbar.
|
||
242 code, m := speak(t, d, `{"text":"gesund"}`)
|
||
243 if code != 202 || m["id"] == "" {
|
||
244 t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m)
|
||
245 }
|
||
246 fake.waitHits(t, 1)
|
||
247 }
|
||
248
|
||
249 // TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version,
|
||
250 // Mute aus, kein current, Provider aufgelöst).
|
||
251 func TestStatusVertrag(t *testing.T) {
|
||
252 root := t.TempDir()
|
||
253 fake := newFakeProvider(t, 0)
|
||
254 d := startDaemon(t, root, fake.srv.URL, nil)
|
||
255
|
||
256 st := getStatus(t, d)
|
||
257 if st.Version != "dev" {
|
||
258 t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version)
|
||
259 }
|
||
260 if st.Muted {
|
||
261 t.Error("muted = true im Frischstart, want false")
|
||
262 }
|
||
263 if st.Current != nil {
|
||
264 t.Errorf("current = %+v, want null im Leerlauf", st.Current)
|
||
265 }
|
||
266 if st.Queue != 0 {
|
||
267 t.Errorf("queue = %d, want 0", st.Queue)
|
||
268 }
|
||
269 // Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt.
|
||
270 if st.Provider != "elevenlabs" {
|
||
271 t.Errorf("provider = %q, want elevenlabs", st.Provider)
|
||
272 }
|
||
273 }
|
||
274
|
||
|