Files
talk/.superpowers/sdd/plan/review-dc94f45..24ceff3.diff
T

2713 lines
145 KiB
Diff

# Review package: dc94f45..HEAD
## Commits
24ceff3 docs(readme): Rate-Limit, Ruhezeiten und now-Ausbruch dokumentieren
5979d45 test: Prozessgrenzen-Tests für Rate-Limit und Ruhezeiten
b56c8ab feat(api): /speak meldet Gesetz-Verwürfe, /status zeigt Gesetz-State
5385a4b feat(queue): Rate-Limit und Ruhezeiten als Gesetz im Worker-Loop
b20e6dd feat(config): Gesetz-Felder rate_limit, allow_high_burst, quiet_hours, quiet_mode
## Files changed
README.md | 58 ++++++--
cmd/talk/control.go | 45 +++++-
cmd/talk/daemon.go | 29 ++++
cmd/talkd/main.go | 33 ++--
internal/api/doc.go | 10 +-
internal/api/server.go | 45 +++++-
internal/api/types.go | 59 +++++++-
internal/config/config.go | 92 +++++++++++-
internal/config/config_test.go | 179 ++++++++++++++++++++++
internal/queue/doc.go | 8 +-
internal/queue/queue.go | 240 ++++++++++++++++++++++++------
internal/queue/queue_test.go | 238 +++++++++++++++++++++++++++--
internal/queue/quiet.go | 109 ++++++++++++++
internal/queue/quiet_test.go | 131 ++++++++++++++++
internal/queue/rate.go | 136 +++++++++++++++--
test/harness_test.go | 26 ++++
test/ratelimit_test.go | 331 +++++++++++++++++++++++++++++++++++++++++
17 files changed, 1646 insertions(+), 123 deletions(-)
## Diff
README.md --- 1/3 --- Text
33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm
.. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel`. Die übrig
.. en .. en
34 Befehle sind Stubs und melden `not 34 Befehle sind Stubs und melden `not
.. implemented yet` (Exit-Code ≠ 0). .. implemented yet` (Exit-Code ≠ 0).
35 35
36 ## Daemon talkd (Task 04) 36 ## Daemon talkd (Task 04 + 05)
37 37
38 `talkd` besitzt die Ansage-Queue un 38 `talkd` besitzt die Ansage-Queue un
.. d spricht seriell ab — genau eine A .. d spricht seriell ab — genau eine A
.. nsage .. nsage
39 gleichzeitig, Reihenfolge `now` > ` 39 gleichzeitig, Reihenfolge `now` > `
.. high` > `normal` > `low` (FIFO je .. high` > `normal` > `low` (FIFO je
README.md --- 2/3 --- Text
42 42
43 | Endpunkt | Wirkung | 43 | Endpunkt | Wirkung |
44 | --- | --- | 44 | --- | --- |
45 | `POST /speak` | `{text, priority? 45 | `POST /speak` | `{text, priority?
.. , provider?, voice?, lang?, now?}` .. , provider?, voice?, lang?, now?}`
.. → `202 {"id":"…"}`; bei Mute: `202 .. → `202 {"id":"…"}`; bei Mute: `202
.. {"id":"","muted":true}` (Ansage ver .. {"id":"","muted":true}`; bei Gesetz
.. worfen — dokumentiertes Verhalten) .. -Verwurf: `202 {"id":"","discarded"
.. | .. :"rate_limit"\|"quiet_hours"}` (+ `
.. .. "toast":true` im Toast-Modus) — ver
.. .. worfen, dokumentiertes Verhalten |
46 | `GET /status` | `{"version", "que 46 | `GET /status` | `{"version", "que
.. ue", "queue_ids", "current", "muted .. ue", "queue_ids", "current", "muted
.. ", "provider"}` | .. ", "provider", "rate_limit": {"inte
.. .. rval", "allow_high_burst", "next_sl
.. .. ot"}, "quiet_hours": {"window", "ac
.. .. tive", "mode", "until"}}` |
47 | `POST /cancel` | bricht die laufe 47 | `POST /cancel` | bricht die laufe
.. nde Ansage ab + leert die Queue → ` .. nde Ansage ab + leert die Queue → `
.. 200 {"cancelled":n}` | .. 200 {"cancelled":n}` |
48 | `POST /mute` / `POST /unmute` | p 48 | `POST /mute` / `POST /unmute` | p
.. ersistenter Mute-State → `200 {"mut .. ersistenter Mute-State → `200 {"mut
.. ed":bool}` | .. ed":bool}` |
49 49
50 - **CLI-Verhalten:** `talk say` (un 50 - **CLI-Verhalten:** `talk say` (un
.. d `talk "Text"`) erkennt den Daemon .. d `talk "Text"`) erkennt den Daemon
.. via .. via
51 `GET /status`. Läuft er: enqueue 51 `GET /status`. Läuft er: enqueue
.. + sofortige Rückkehr (non-blocking, .. + sofortige Rückkehr (non-blocking,
52 stdout erhält die Item-ID); `--wa 52 stdout erhält die Item-ID); `--wa
.. it` pollt bis zur Fertigstellung; .. it` pollt bis zur Fertigstellung;
53 `--now` springt vor die Warteschl 53 `--now` springt vor die Warteschl
.. ange; bei Mute sagt die CLI `muted` .. ange; bei Mute sagt die CLI `muted`
.. . .. ,
54 `talk mute`/`unmute`/`status`/`ca 54 bei Gesetz-Verwurf `discarded: <g
.. ncel` sind Daemon-Aufrufe mit plain .. rund>`. `talk mute`/`unmute`/`statu
.. .. s`/
55 Output. 55 `cancel` sind Daemon-Aufrufe mit
.. .. plain Output.
56 - **Mute ist persistent** (Datei `m 56 - **Mute ist persistent** (Datei `m
.. ute` im Config-Dir, Inhalt `true`/ .. ute` im Config-Dir, Inhalt `true`/
57 `false`) und überlebt Daemon-Rest 57 `false`) und überlebt Daemon-Rest
.. arts. `talk mute` ohne laufenden Da .. arts. `talk mute` ohne laufenden Da
.. emon .. emon
58 schreibt die Datei direkt — der D 58 schreibt die Datei direkt — der D
.. aemon liest sie bei jeder Ansage ne .. aemon liest sie bei jeder Ansage ne
.. u, .. u,
README.md --- 3/3 --- Text
61 sequenziell weiter — serialisiert 61 sequenziell weiter — serialisiert
.. über die Lock-Datei `talk.lock` im .. über die Lock-Datei `talk.lock` im
62 Config-Dir (Kollision = warten; v 62 Config-Dir (Kollision = warten; v
.. erwaisste Locks gelten nach 60 s al .. erwaisste Locks gelten nach 60 s al
.. s .. s
63 verfallen). Mute wird auch hier r 63 verfallen). Mute wird auch hier r
.. espektiert. Nichts bricht still. .. espektiert. Nichts bricht still.
.. 64
64 - Rate-Limit und Ruhezeiten folgen 65 ### Rate-Limit + Ruhezeiten (Task 0
.. in Task 05; die Rate-Limit-Struktur .. 5)
.. 66
.. 67 Der Daemon erzwingt das Anti-Spam-G
.. .. esetz als Backstop unter allem
.. 68 Prompt-Verhalten. **Rate-Limit:** J
.. .. ede *gestartete* Ansage bucht ein
.. 69 Zeitfenster — Default **1 Ansage /
.. .. 30 s**. Bis zum Fenster-Ablauf gilt
.. .. :
.. 70
.. 71 - `priority=low` wird **verworfen**
.. .. (schon beim Eintritt, die 202-Antw
.. .. ort
.. 72 nennt `discarded: "rate_limit"`);
.. .. war das Fenster beim Eintritt noch
.. 73 frei, entscheidet der Worker spät
.. .. er.
.. 74 - `normal` **parkt** in der Queue u
.. .. nd spielt zum freien Slot (in `/sta
.. .. tus`
.. 75 als `rate_limit.next_slot` sichtb
.. .. ar).
.. 76 - `priority=high` darf mit `queue.a
.. .. llow_high_burst = true` (Default)
.. 77 **bewusst verstoßen**; ohne Burst
.. .. -Recht wartet high wie normal.
.. 78 - `now:true` **umgeht auch das Rate
.. .. -Limit** — der dokumentierte Ausbru
.. .. ch
65 existiert bereits (`internal/queu 79 für Dringendes (startet trotzdem
.. e`), ist in diesem Stand aber bewus .. ein neues Fenster).
.. st ..
.. 80
.. 81 **Ruhezeiten:** `quiet_hours = "18:
.. .. 00-08:00"` (lokale Zeit, überspannt
.. 82 Mitternacht; leer/`off` = aus — bew
.. .. usst kein Default-Fenster, Ruhezeit
.. .. en
.. 83 sind Opt-in wie Mute). In der Ruhez
.. .. eit verfällt Audio, auch für high
.. 84 (Burst ist ein Rate-Recht, kein Ruh
.. .. e-Recht):
.. 85
66 noch nicht angebunden (Default: u 86 - `quiet_mode = "discard"` (Default
.. nbegrenzt). .. ): still verwerfen.
.. 87 - `quiet_mode = "toast"`: Benachric
.. .. htigung statt Audio — derzeit eine
.. 88 Protokoll-Zeile über den Notifica
.. .. tion-Pfad (OS-Toast folgt mit der
.. 89 Notification-Anbindung).
.. 90 - `now:true` **bricht auch die Ruhe
.. .. zeit** (bewusster Ausbruch).
.. 91
.. 92 **Rangordnung:** manuelles Mute gew
.. .. innt immer > `now` > Ruhezeit >
.. 93 Rate-Limit. Konfiguration (config.t
.. .. oml, Env schlägt Datei):
.. 94
.. 95 ```toml
.. 96 [queue]
.. 97 rate_limit = "30s" # Dauer;
.. .. "0s" = unbegrenzt (TALK_RATE_LIMIT)
.. 98 allow_high_burst = true # high da
.. .. rf verstoßen (TALK_ALLOW_HIGH_BURST
.. .. )
.. 99
.. 100 quiet_hours = "18:00-08:00" # leer
.. ... = aus; Env TALK_QUIET_HOURS ("off"
.. ... = aus)
.. 101 quiet_mode = "discard" # disc
.. ... ard | toast (TALK_QUIET_MODE)
.. 102 ```
.. 103
.. 104 `talk status` zeigt den Gesetz-Stat
.. ... e: `rate-limit: 30s (high-burst
.. 105 erlaubt), nächster slot …` und `ruh
.. ... ezeit: 18:00-08:00 (modus discard,
.. 106 aktiv bis 08:00)`.
67 107
68 ### Provider-Kette (Task 03) 108 ### Provider-Kette (Task 03)
69 109
cmd/talk/control.go --- 1/3 --- Go
5 5 "fmt"
6 6 "io"
7 7 "path/filepath"
. 8 "time"
8 9
9 10 "code.m3ta.dev/m3tam3re/talk/internal/api"
10 11 "code.m3ta.dev/m3tam3re/talk/internal/config"
cmd/talk/control.go --- 2/3 --- Go
71 72
72 // runStatus implementiert `talk st 73 // runStatus implementiert `talk st
.. atus`: plain Block aus GET /status .. atus`: plain Block aus GET /status
.. — .. —
73 // Daemon-Adresse + Version, Queue- 74 // Daemon-Adresse + Version, Queue-
.. Länge, aktuelles Item, Mute-State, .. Länge, aktuelles Item, Mute-State,
74 // aktiver Provider. Ohne Daemon: k 75 // aktiver Provider, Gesetz-State (
.. larer Fehler (status ist eine Anfra .. Rate-Limit, Ruhezeit). Ohne Daemon:
.. ge an ..
.. 76 // klarer Fehler (status ist eine A
.. .. nfrage an den Daemon; der Degraded
.. .. Mode
75 // den Daemon; der Degraded Mode ha 77 // hat keinen Status).
.. t keinen Status). ..
76 func runStatus(args []string, opts 78 func runStatus(args []string, opts
.. *sayOptions, stdin io.Reader, stdou .. *sayOptions, stdin io.Reader, stdou
.. t, stderr io.Writer) int { .. t, stderr io.Writer) int {
77 cfg, err := config.Load(config. 79 cfg, err := config.Load(config.
.. Options{Getenv: getenv}) .. Options{Getenv: getenv})
78 if err != nil { 80 if err != nil {
cmd/talk/control.go --- 3/3 --- Go
101 103 }
102 104 fmt.Fprintf(stdout, "mute: %s\n", muteState)
103 105 fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
... 106 printLawState(stdout, st)
104 107 return exitOK
105 108 }
... 109
... 110 // printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
... 111 // Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
... 112 func printLawState(stdout io.Writer, st *api.StatusResponse) {
... 113 if st.RateLimit != nil {
... 114 interval := st.RateLimit.Interval
... 115 if interval == "0s" {
... 116 interval = "aus (unbegrenzt)"
... 117 }
... 118 burst := "verboten"
... 119 if st.RateLimit.AllowHighBurst {
... 120 burst = "erlaubt"
... 121 }
... 122 line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
... 123 if st.RateLimit.NextSlot != nil {
... 124 if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
... 125 line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
... 126 }
... 127 }
... 128 fmt.Fprintln(stdout, line)
... 129 }
... 130 if st.QuietHours != nil {
... 131 window := st.QuietHours.Window
... 132 if window == "" {
... 133 window = "aus"
... 134 }
... 135 state := "inaktiv"
... 136 if st.QuietHours.Active {
... 137 state = "aktiv"
... 138 if st.QuietHours.Until != nil {
... 139 if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
... 140 state += " bis " + ts.Format("15:04")
... 141 }
... 142 }
... 143 }
... 144 fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
... 145 }
... 146 }
106 147
107 148 // runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
108 149 // Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
cmd/talk/daemon.go --- 1/2 --- Go
9 9
10 10 "code.m3ta.dev/m3tam3re/talk/internal/api"
11 11 "code.m3ta.dev/m3tam3re/talk/internal/config"
.. 12 "code.m3ta.dev/m3tam3re/talk/internal/queue"
12 13 )
13 14
14 15 // Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
cmd/talk/daemon.go --- 2/2 --- Go
64 65 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
65 66 return exitOK, true
66 67 }
.. 68 if resp.Discarded != "" {
.. 69 // Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
.. 70 // Verhalten wie bei Mute — plain Token statt Fehler; --wait hat
.. 71 // nichts zu warten, es gibt keine Item-ID.
.. 72 line := "discarded: " + resp.Discarded
.. 73 if resp.Toast {
.. 74 line += " (toast)"
.. 75 }
.. 76 fmt.Fprintln(stdout, line)
.. 77 fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast))
.. 78 return exitOK, true
.. 79 }
67 80 fmt.Fprintln(stdout, resp.ID)
68 81 if !opts.wait {
69 82 return exitOK, true
70 83 }
71 84 return waitForItem(client, resp.ID, stderr), true
72 85 }
.. 86
.. 87 // discardHint übersetzt den Verwurfs-Grund der Daemon-Antwort in einen
.. 88 // menschlichen Hinweis.
.. 89 func discardHint(reason string, toast bool) string {
.. 90 switch reason {
.. 91 case queue.DiscardQuietHours:
.. 92 if toast {
.. 93 return "Ruhezeit aktiv — Ansage durch Toast ersetzt (kein Audio)"
.. 94 }
.. 95 return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
.. 96 case queue.DiscardRateLimit:
.. 97 return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
.. 98 default:
.. 99 return "Ansage verworfen (" + reason + ")"
.. 100 }
.. 101 }
73 102
74 103 // waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
75 104 // sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
cmd/talkd/main.go --- 1/3 --- Go
1 // talkd ist der Daemon für talk 2. 1 // talkd ist der Daemon für talk 2.
. 0: HTTP-API auf 127.0.0.1 (Default- . 0: HTTP-API auf 127.0.0.1 (Default-
. Port . Port
2 // 7800, server.port/TALK_PORT), se 2 // 7800, server.port/TALK_PORT), se
. rialisierte Ansage-Queue mit Priori . rialisierte Ansage-Queue mit Priori
. täten . täten
3 // und now-Sprung, persistenter Mut 3 // und now-Sprung, persistenter Mut
. e-State (Datei mute im Config-Dir). . e-State (Datei mute im Config-Dir)
. . —
4 // 4 // und seit Task 05 das Anti-Spam-G
. . esetz: Rate-Limit (queue.rate_limit
. . ,
5 // Task 04: POST /speak, GET /statu 5 // Default 1 Ansage / 30 s; low übe
. s, POST /cancel, POST /mute, POST / . r Budget wird verworfen, normal par
. unmute. . kt,
6 // Rate-Limit/Ruhezeiten folgen in 6 // high darf mit queue.allow_high_b
. Task 05, CORS/MCP in Task 06 — die . urst verstoßen) plus Ruhezeiten
7 // Rate-Limit-Struktur existiert be 7 // (quiet_hours/quiet_mode; now:tru
. reits (internal/queue/rate.go), ist . e bricht bewusst aus beiden aus).
. hier .
8 // aber bewusst noch nicht angebund 8 // CORS/MCP folgen in Task 06.
. en (Task-04-Default: unbegrenzt). .
9 package main 9 package main
10 10
11 import ( 11 import (
cmd/talkd/main.go --- 2/3 --- Go
88 88 return chain.Speak(ctx, item.Text, lang)
89 89 }
.. 90
.. 91 // Anti-Spam-Gesetz (Task 05): Rate-Limit + High-Burst + Ruhezeiten.
.. 92 // config.Load hat quiet_hours bereits validiert — das Parsen hier kann
.. 93 // praktisch nicht scheitern, aber ein Fehler darf nie still werden.
.. 94 quiet, err := queue.ParseQuietHours(cfg.QuietHours)
.. 95 if err != nil {
.. 96 return err
.. 97 }
.. 98 laws := queue.Laws{
.. 99 RateLimit: cfg.Queue.RateLimit,
.. 100 AllowHighBurst: cfg.Queue.AllowHighBurst,
.. 101 Quiet: quiet,
.. 102 QuietMode: cfg.QuietMode,
.. 103 }
90 104
91 105 q := queue.New(laws, speak, lw)
92 106
93 107 // Persistenter Mute-State (Datei mute im Config-Dir). Ohne Config-Dir
94 108 // läuft der Daemon ohne Mute weiter (dokumentierte Warnung) statt zu
cmd/talkd/main.go --- 3/3 --- Go
125 srv := &http.Server{Handler: ha 139 srv := &http.Server{Handler: ha
... ndler, ReadHeaderTimeout: 5 * time. ... ndler, ReadHeaderTimeout: 5 * time.
... Second} ... Second}
126 errCh := make(chan error, 1) 140 errCh := make(chan error, 1)
127 go func() { errCh <- srv.Serve( 141 go func() { errCh <- srv.Serve(
... ln) }() ... ln) }()
128 log.Printf("listening on http:/ 142 log.Printf("listening on http:/
... /%s (version %s, audio sink %s, mut ... /%s (version %s, audio sink %s, mut
... e=%v)", ... e=%v, rate-limit %s, high-burst=%v,
... ... ruhezeit=%q modus %s)",
129 addr, versionString(), sink 143 addr, versionString(), sink
... .Name(), mute.Muted()) ... .Name(), mute.Muted(),
... 144 cfg.Queue.RateLimit, cfg.Qu
... ... eue.AllowHighBurst, cfg.QuietHours,
... ... cfg.QuietMode)
130 145
131 sig := make(chan os.Signal, 1) 146 sig := make(chan os.Signal, 1)
132 signal.Notify(sig, os.Interrupt 147 signal.Notify(sig, os.Interrupt
... , syscall.SIGTERM) ... , syscall.SIGTERM)
internal/api/doc.go --- Go
1 // Package api definiert die HTTP-Obe 1 // Package api definiert die HTTP-Obe
. rfläche des Daemons auf 127.0.0.1: . rfläche des Daemons auf 127.0.0.1:
2 // POST /speak (enqueue, 202 {id}; be 2 // POST /speak (enqueue, 202 {id}; be
. i Mute 202 {muted:true}), GET /status . i Mute 202 {muted:true}; bei Gesetz-
. , .
. 3 // Verwurf 202 {discarded:"rate_limit
. . "|"quiet_hours"} — Task 05),
. 4 // GET /status (inkl. Rate-Limit- und
. . Ruhezeit-State), POST /cancel,
3 // POST /cancel, POST /mute, POST /un 5 // POST /mute, POST /unmute. CORS für
. mute (Task 04). CORS für /speak und . /speak und der MCP-Endpoint
4 // der MCP-Endpoint (Streamable HTTP) 6 // (Streamable HTTP) folgen in Task 0
. folgen in Task 06. Der Client-Teil . 6. Der Client-Teil ist die CLI-Seite
5 // ist die CLI-Seite derselben Vertra 7 // derselben Vertragsschnittstelle.
. gsschnittstelle. .
6 package api 8 package api
internal/api/server.go --- 1/5 --- Go
5 5 "fmt"
6 6 "net/http"
7 7 "strings"
. 8 "time"
8 9
9 10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
10 11 )
internal/api/server.go --- 2/5 --- Go
68 69
69 // speak behandelt POST /speak: val 70 // speak behandelt POST /speak: val
.. idieren, bei Mute verwerfen (202, .. idieren, bei Mute verwerfen (202,
70 // dokumentiertes Verhalten — bewus 71 // dokumentiertes Verhalten — bewus
.. st kein Fehler, damit Aufrufer kein .. st kein Fehler, damit Aufrufer kein
.. e .. e
71 // Sonderbehandlung brauchen), sons 72 // Sonderbehandlung brauchen), sons
.. t enqueue → 202 {id}. .. t enqueue → 202 {id}. Das Gesetz
.. 73 // (Rate-Limit-Budget, Ruhezeit) ka
.. .. nn beim Eintritt ebenfalls verwerfe
.. .. n —
.. 74 // die Antwort nennt dann den Grund
.. .. ({"discarded":"…"}).
72 func (h *handler) speak(w http.Resp 75 func (h *handler) speak(w http.Resp
.. onseWriter, r *http.Request) { .. onseWriter, r *http.Request) {
73 var req SpeakRequest 76 var req SpeakRequest
74 if err := json.NewDecoder(http. 77 if err := json.NewDecoder(http.
.. MaxBytesReader(w, r.Body, maxBodyBy .. MaxBytesReader(w, r.Body, maxBodyBy
.. tes)).Decode(&req); err != nil { .. tes)).Decode(&req); err != nil {
internal/api/server.go --- 3/5 --- Go
100 return 103 return
101 } 104 }
102 105
103 id, err := h.queue.Enqueue(queu 106 adm, err := h.queue.Enqueue(que
... e.Item{ ... ue.Item{
104 Text: req.Text, 107 Text: req.Text,
105 Lang: req.Lang, 108 Lang: req.Lang,
106 Provider: req.Provider, 109 Provider: req.Provider,
internal/api/server.go --- 4/5 --- Go
112 writeError(w, http.StatusSe 115 writeError(w, http.StatusSe
... rviceUnavailable, "enqueue: "+err.E ... rviceUnavailable, "enqueue: "+err.E
... rror()) ... rror())
113 return 116 return
114 } 117 }
... 118 if adm.Discarded != "" {
... 119 // Gesetz-Verwurf (Rate-Lim
... ... it-Budget / Ruhezeit): dokumentiert
... ... es
... 120 // Verhalten wie bei Mute —
... ... 202 mit Grund statt Fehler, damit
... 121 // Aufrufer keine Sonderbeh
... ... andlung brauchen.
... 122 writeJSON(w, http.StatusAcc
... ... epted, SpeakResponse{Discarded: adm
... ... .Discarded, Toast: adm.Toast})
... 123 return
... 124 }
115 writeJSON(w, http.StatusAccepte 125 writeJSON(w, http.StatusAccepte
... d, SpeakResponse{ID: id}) ... d, SpeakResponse{ID: adm.ID})
116 } 126 }
117 127
118 // status behandelt GET /status: Qu 128 // status behandelt GET /status: Qu
... eue-Länge (+ wartende IDs), aktuell ... eue-Länge (+ wartende IDs), aktuell
... es ... es
119 // Item, Mute-State, aktiver Provid 129 // Item, Mute-State, aktiver Provid
... er, Version. ... er, Version — und seit Task 05 den
... 130 // Gesetz-State (Rate-Limit mit näc
... ... hstem Slot, Ruhezeit-Fenster).
120 func (h *handler) status(w http.Res 131 func (h *handler) status(w http.Res
... ponseWriter, r *http.Request) { ... ponseWriter, r *http.Request) {
121 pending := h.queue.Pending() 132 pending := h.queue.Pending()
122 ids := make([]string, len(pendi 133 ids := make([]string, len(pendi
... ng)) ... ng))
internal/api/server.go --- 5/5 --- Go
140 151 if h.provider != nil {
141 152 st.Provider = h.provider()
142 153 }
... 154 st.RateLimit, st.QuietHours = lawState(h.queue.LawState())
143 155 writeJSON(w, http.StatusOK, st)
144 156 }
... 157
... 158 // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
... 159 // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
... 160 // frei / Ruhezeit inaktiv).
... 161 func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
... 162 now := time.Now()
... 163 rl := &RateLimitState{
... 164 Interval: l.RateLimit.String(),
... 165 AllowHighBurst: l.AllowHighBurst,
... 166 }
... 167 if l.NextSlot.After(now) {
... 168 slot := l.NextSlot.Format(time.RFC3339)
... 169 rl.NextSlot = &slot
... 170 }
... 171 qh := &QuietHoursState{
... 172 Window: l.Quiet.String(),
... 173 Active: l.QuietActive,
... 174 Mode: l.QuietMode,
... 175 }
... 176 if !l.QuietUntil.IsZero() {
... 177 until := l.QuietUntil.Format(time.RFC3339)
... 178 qh.Until = &until
... 179 }
... 180 return rl, qh
... 181 }
145 182
146 183 // cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
147 184 func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
internal/api/types.go --- 1/2 --- Go
27 27
28 // SpeakResponse ist die Antwort vo 28 // SpeakResponse ist die Antwort vo
.. n POST /speak: 202 {"id":"…"} — bei .. n POST /speak: 202 {"id":"…"} — bei
29 // aktivem Mute 202 {"id":"","muted 29 // aktivem Mute 202 {"id":"","muted
.. ":true} (Ansage verworfen; dokument .. ":true} (Ansage verworfen; dokument
.. iertes .. iertes
30 // Verhalten, kein Fehler). 30 // Verhalten, kein Fehler). Seit Ta
.. .. sk 05 meldet verworfenen Spam ebens
.. .. o
.. 31 // dokumentiert: 202 {"id":"","disc
.. .. arded":"rate_limit"|"quiet_hours"}
.. .. —
.. 32 // plus "toast":true, wenn die Ruhe
.. .. zeit-Ansage durch eine Benachrichti
.. .. gung
.. 33 // ersetzt wurde (quiet_mode = "toa
.. .. st").
31 type SpeakResponse struct { 34 type SpeakResponse struct {
32 ID string `json:"id"` 35 ID string `json:"id"`
33 Muted bool `json:"muted,omite 36 Muted bool `json:"muted,omite
.. mpty"` .. mpty"`
.. 37
.. 38 // Discarded ist der Verwurfs-G
.. .. rund ("rate_limit" | "quiet_hours")
.. .. ;
.. 39 // leer, wenn die Ansage aufgen
.. .. ommen wurde.
.. 40 Discarded string `json:"discard
.. .. ed,omitempty"`
.. 41
.. 42 // Toast meldet die Ersetzung d
.. .. urch eine Benachrichtigung.
.. 43 Toast bool `json:"toast,omitemp
.. .. ty"`
34 } 44 }
35 45
36 // CurrentItem beschreibt das laufe 46 // CurrentItem beschreibt das laufe
.. nde Item in GET /status. .. nde Item in GET /status.
internal/api/types.go --- 2/2 --- Go
44 // StatusResponse ist die Antwort v 54 // StatusResponse ist die Antwort v
.. on GET /status: Queue-Länge, aktuel .. on GET /status: Queue-Länge, aktuel
.. les .. les
45 // Item (null im Leerlauf), Mute-St 55 // Item (null im Leerlauf), Mute-St
.. ate, aktiver Provider, Daemon-Versi .. ate, aktiver Provider, Daemon-Versi
.. on. .. on.
46 // QueueIDs ist die Ergänzung der w 56 // QueueIDs ist die Ergänzung der w
.. artenden IDs in Abarbeitungs-Reihen .. artenden IDs in Abarbeitungs-Reihen
.. folge .. folge
47 // — die CLI braucht sie für ihr -- 57 // — die CLI braucht sie für ihr --
.. wait-Polling. .. wait-Polling. Task 05 ergänzt addit
.. .. iv
.. 58 // den Gesetz-State (rate_limit, qu
.. .. iet_hours).
48 type StatusResponse struct { 59 type StatusResponse struct {
49 Version string `json:"ve 60 Version string `js
.. rsion"` .. on:"version"`
50 Queue int `json:"qu 61 Queue int `js
.. eue"` .. on:"queue"`
51 QueueIDs []string `json:"qu 62 QueueIDs []string `js
.. eue_ids,omitempty"` .. on:"queue_ids,omitempty"`
52 Current *CurrentItem `json:"cu 63 Current *CurrentItem `js
.. rrent"` .. on:"current"`
53 Muted bool `json:"mu 64 Muted bool `js
.. ted"` .. on:"muted"`
54 Provider string `json:"pr 65 Provider string `js
.. ovider"` .. on:"provider"`
.. 66 RateLimit *RateLimitState `js
.. .. on:"rate_limit,omitempty"`
.. 67 QuietHours *QuietHoursState `js
.. .. on:"quiet_hours,omitempty"`
55 } 68 }
.. 69
.. 70 // RateLimitState ist der Rate-Limi
.. .. t-State in GET /status (Task 05).
.. 71 type RateLimitState struct {
.. 72 // Interval ist der konfigurier
.. .. te Mindestabstand als Dauer-String
.. 73 // ("30s"; "0s" = unbegrenzt).
.. 74 Interval string `json:"interval
.. .. "`
.. 75
.. 76 // AllowHighBurst meldet, ob pr
.. .. iority=high verstoßen darf.
.. 77 AllowHighBurst bool `json:"allo
.. .. w_high_burst"`
.. 78
.. 79 // NextSlot ist der nächste fre
.. .. ie Ansage-Slot als RFC3339-Zeitstem
.. .. pel;
.. 80 // null, wenn sofort gespielt w
.. .. erden dürfte (oder unbegrenzt).
.. 81 NextSlot *string `json:"next_sl
.. .. ot"`
.. 82 }
.. 83
.. 84 // QuietHoursState ist der Ruhezeit
.. .. -State in GET /status (Task 05).
.. 85 type QuietHoursState struct {
.. 86 // Window ist das konfigurierte
.. .. Fenster ("18:00-08:00"; "" = deakt
.. .. iviert).
.. 87 Window string `json:"window"`
.. 88
.. 89 // Active meldet, ob die lokale
.. .. Zeit gerade im Fenster liegt.
.. 90 Active bool `json:"active"`
.. 91
.. 92 // Mode ist der konfigurierte R
.. .. uhezeit-Modus ("discard" | "toast")
.. .. .
.. 93 Mode string `json:"mode"`
.. 94
.. 95 // Until ist das Fenster-Ende a
.. .. ls RFC3339-Zeitstempel, wenn aktiv;
.. 96 // sonst null.
.. 97 Until *string `json:"until"`
.. 98 }
56 99
57 // PendingOrCurrent meldet, ob die 100 // PendingOrCurrent meldet, ob die
.. Ansage mit der ID noch wartet oder ... Ansage mit der ID noch wartet oder
58 // gerade läuft — Grundlage des --w 101 // gerade läuft — Grundlage des --w
.. ait-Pollings der CLI. Ist die Ansag ... ait-Pollings der CLI. Ist die Ansag
.. e ... e
internal/config/config.go --- 1/7 --- Go
6 6 "os"
7 7 "path/filepath"
8 8 "strconv"
. 9 "time"
9 10
10 11 "github.com/BurntSushi/toml"
.. 12
.. 13 "code.m3ta.dev/m3tam3re/talk/internal/queue"
11 14 )
12 15
13 16 // Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
internal/config/config.go --- 2/7 --- Go
21 // 127.0.0.1 (Task-04-Brief: „Port 24 // 127.0.0.1 (Task-04-Brief: „Port
.. Default 7800"). .. Default 7800").
22 const DefaultServerPort = 7800 25 const DefaultServerPort = 7800
.. 26
.. 27 // DefaultRateLimit ist das Daemon-
.. .. Gesetz gegen Spam (Task-05-Brief):
.. 28 // Default 1 Ansage / 30 s. „0s" sc
.. .. haltet das Limit ab.
.. 29 const DefaultRateLimit = 30 * time.
.. .. Second
23 30
24 // Config ist der effektiv geladene 31 // Config ist der effektiv geladene
.. Konfigurations-Stand von talk. Tas .. Konfigurations-Stand von talk. Tas
.. k 02 .. k 02
25 // führt audio.sink; Task 03 ergänz 32 // führt audio.sink; Task 03 ergänz
.. t den Provider-Pin, Provider-Endpun .. t den Provider-Pin, Provider-Endpun
.. kte .. kte
26 // und das Voice-Mapping; Task 04 d 33 // und das Voice-Mapping; Task 04 d
.. en Daemon-Port (server.port, TALK_P .. en Daemon-Port (server.port, TALK_P
.. ORT); .. ORT);
.. 34 // Task 05 die Anti-Spam-Gesetze (q
.. .. ueue.rate_limit, queue.allow_high_b
.. .. urst,
27 // spätere Tasks ergänzen weitere F 35 // quiet_hours, quiet_mode); später
.. elder (queue …), ohne die Auflösung .. e Tasks ergänzen weitere Felder, oh
.. s- .. ne die
28 // reihenfolge zu ändern. 36 // Auflösungs-Reihenfolge zu ändern
.. .. .
29 type Config struct { 37 type Config struct {
30 Audio Audio `toml:"audio"` 38 Audio Audio `toml:"audio"`
31 39
32 // Server steuert den Daemon-HT 40 // Server steuert den Daemon-HT
.. TP-Server (talkd) auf 127.0.0.1. .. TP-Server (talkd) auf 127.0.0.1.
33 Server Server `toml:"server"` 41 Server Server `toml:"server"`
.. 42
.. 43 // Queue steuert die Anti-Spam-
.. .. Gesetze des Daemons (Task 05).
.. 44 Queue Queue `toml:"queue"`
34 45
35 // Provider pinnt die Kette auf 46 // Provider pinnt die Kette auf
.. genau einen Provider (z. B. .. genau einen Provider (z. B.
36 // `provider = "elevenlabs"`); 47 // `provider = "elevenlabs"`);
.. leer = komplette Default-Kette .. leer = komplette Default-Kette
internal/config/config.go --- 3/7 --- Go
49 60 // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
50 61 Voice map[string]map[string]string `toml:"voice"`
.. 62
.. 63 // QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
.. 64 // (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
.. 65 // Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit
.. 66 // verwirft, würde jeden Cron/Test in den Abendstunden stilllegen —
.. 67 // Ruhezeiten sind Opt-in wie Mute.
.. 68 QuietHours string `toml:"quiet_hours"`
.. 69
.. 70 // QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default,
.. 71 // still verwerfen) oder "toast" (Benachrichtigung statt Audio).
.. 72 QuietMode string `toml:"quiet_mode"`
51 73 }
52 74
53 75 // Audio steuert die Audio-Ausgabe.
internal/config/config.go --- 4/7 --- Go
65 87 Port int `toml:"port"`
66 88 }
.. 89
.. 90 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
.. 91 type Queue struct {
.. 92 // RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als
.. 93 // Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s.
.. 94 RateLimit time.Duration `toml:"rate_limit"`
.. 95
.. 96 // AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
.. 97 // verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
.. 98 AllowHighBurst bool `toml:"allow_high_burst"`
.. 99 }
67 100
68 101 // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
69 102 // Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
internal/config/config.go --- 5/7 --- Go
82 115 return Config{
83 116 Audio: Audio{Sink: SinkOto},
84 117 Server: Server{Port: DefaultServerPort},
.. 118 Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
.. 119 QuietMode: queue.QuietModeDiscard,
85 120 }
86 121 }
87 122
internal/config/config.go --- 6/7 --- Go
99 } 134 }
100 135
101 // Load löst die Konfiguration schi 136 // Load löst die Konfiguration schi
... chtweise auf: Defaults → config.tom ... chtweise auf: Defaults → config.tom
... l ... l
102 // (falls vorhanden) → Env-Override 137 // (falls vorhanden) → Env-Override
... s (TALK_AUDIO_SINK schlägt die Date ... s (TALK_* schlägt die Datei). Env-
... i). ... und
103 // Env- und Datei-Validierungsfehle 138 // Datei-Validierungsfehler werden
... r werden als klarer Fehler gemeldet ... als klarer Fehler gemeldet, nie sti
... , nie ... ll
104 // still ignoriert. Fehlt das Confi 139 // ignoriert. Fehlt das Config-Verz
... g-Verzeichnis (z. B. keine HOME-Var ... eichnis (z. B. keine HOME-Variable)
... iable), ... ,
105 // läuft talk mit Defaults + Env we 140 // läuft talk mit Defaults + Env we
... iter — die Datei ist optional, kapu ... iter — die Datei ist optional, kapu
... tte ... tte
106 // Umgebungen dürfen talk nicht unb 141 // Umgebungen dürfen talk nicht unb
... rauchbar machen. ... rauchbar machen.
107 func Load(opts Options) (Config, er 142 func Load(opts Options) (Config, er
... ror) { ... ror) {
internal/config/config.go --- 7/7 --- Go
143 } 178 }
144 cfg.Server.Port = port 179 cfg.Server.Port = port
145 } 180 }
... 181 if v := getenv("TALK_RATE_LIMIT
... ... "); v != "" {
... 182 d, err := time.ParseDuratio
... ... n(v)
... 183 if err != nil || d < 0 {
... 184 return cfg, fmt.Errorf(
... ... "TALK_RATE_LIMIT: ungültiger Wert %
... ... q (Dauer, z. B. \"30s\"; \"0s\" = u
... ... nbegrenzt)", v)
... 185 }
... 186 cfg.Queue.RateLimit = d
... 187 }
... 188 if v := getenv("TALK_ALLOW_HIGH
... ... _BURST"); v != "" {
... 189 b, err := strconv.ParseBool
... ... (v)
... 190 if err != nil {
... 191 return cfg, fmt.Errorf(
... ... "TALK_ALLOW_HIGH_BURST: ungültiger
... ... Wert %q (true/false)", v)
... 192 }
... 193 cfg.Queue.AllowHighBurst =
... ... b
... 194 }
... 195 if v := getenv("TALK_QUIET_HOUR
... ... S"); v != "" {
... 196 if v == "off" {
... 197 cfg.QuietHours = ""
... 198 } else {
... 199 if _, err := queue.Pars
... ... eQuietHours(v); err != nil {
... 200 return cfg, fmt.Err
... ... orf("TALK_QUIET_HOURS: ungültiges F
... ... enster %q (HH:MM-HH:MM, \"off\" = d
... ... eaktiviert)", v)
... 201 }
... 202 cfg.QuietHours = v
... 203 }
... 204 }
... 205 if v := getenv("TALK_QUIET_MODE
... ... "); v != "" {
... 206 if v != queue.QuietModeDisc
... ... ard && v != queue.QuietModeToast {
... 207 return cfg, fmt.Errorf(
... ... "TALK_QUIET_MODE: ungültiger Wert %
... ... q (erlaubt: %q, %q)",
... 208 v, queue.QuietModeD
... ... iscard, queue.QuietModeToast)
... 209 }
... 210 cfg.QuietMode = v
... 211 }
146 switch cfg.Audio.Sink { 212 switch cfg.Audio.Sink {
147 case SinkOto, SinkNull: 213 case SinkOto, SinkNull:
148 return cfg, nil ...
149 default: 214 default:
150 return cfg, fmt.Errorf("aud 215 return cfg, fmt.Errorf("aud
... io.sink: ungültiger Wert %q (erlaub ... io.sink: ungültiger Wert %q (erlaub
... t: %q, %q)", ... t: %q, %q)",
151 cfg.Audio.Sink, SinkOto 216 cfg.Audio.Sink, SinkOto
... , SinkNull) ... , SinkNull)
152 } 217 }
... 218 // quiet_hours aus der Datei va
... ... lidieren (Env wurde oben geprüft) u
... ... nd
... 219 // quiet_mode leer (Datei ohne
... ... Wert) auf den Default heben.
... 220 if cfg.QuietHours != "" {
... 221 if _, err := queue.ParseQui
... ... etHours(cfg.QuietHours); err != nil
... ... {
... 222 return cfg, err
... 223 }
... 224 }
... 225 if cfg.QuietMode == "" {
... 226 cfg.QuietMode = queue.Quiet
... ... ModeDiscard
... 227 }
... 228 return cfg, nil
153 } 229 }
internal/config/config_test.go --- 1/2 --- Go
5 5 "path/filepath"
6 6 "strings"
7 7 "testing"
. 8 "time"
. 9
. 10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
8 11 )
9 12
10 13 func env(kv map[string]string) func(string) string {
internal/config/config_test.go --- 2/2 --- Go
353 356 })
354 357 }
358
359 // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
360 // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
361 // Format validiert) samt Env-Overrides und klaren Fehlern.
362 func TestLoadGesetze(t *testing.T) {
363 writeTOML := func(t *testing.T, content string) string {
364 t.Helper()
365 path := filepath.Join(t.TempDir(), "config.toml")
366 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
367 t.Fatal(err)
368 }
369 return path
370 }
371 fehlendeDatei := func(t *testing.T) string {
372 t.Helper()
373 return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
374 }
375
376 t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
377 cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
378 if err != nil {
379 t.Fatalf("Load: %v", err)
380 }
381 if cfg.Queue.RateLimit != DefaultRateLimit {
382 t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
383 }
384 if !cfg.Queue.AllowHighBurst {
385 t.Error("Queue.AllowHighBurst = false, want Default true")
386 }
387 if cfg.QuietHours != "" {
388 t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
389 }
390 if cfg.QuietMode != queue.QuietModeDiscard {
391 t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
392 }
393 })
394
395 t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
396 cfg, err := Load(Options{
397 File: writeTOML(t, `
398 quiet_hours = "18:00-08:00"
399 quiet_mode = "toast"
400
401 [queue]
402 rate_limit = "10s"
403 allow_high_burst = false
404 `),
405 Getenv: env(nil),
406 })
407 if err != nil {
408 t.Fatalf("Load: %v", err)
409 }
410 if cfg.Queue.RateLimit != 10*time.Second {
411 t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
412 }
413 if cfg.Queue.AllowHighBurst {
414 t.Error("Queue.AllowHighBurst = true, want false aus TOML")
415 }
416 if cfg.QuietHours != "18:00-08:00" {
417 t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
418 }
419 if cfg.QuietMode != queue.QuietModeToast {
420 t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
421 }
422 })
423
424 t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
425 cfg, err := Load(Options{
426 File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
427 Getenv: env(nil),
428 })
429 if err != nil {
430 t.Fatalf("Load: %v", err)
431 }
432 if cfg.Queue.RateLimit != 0 {
433 t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
434 }
435 if !cfg.Queue.AllowHighBurst {
436 t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
437 }
438 if cfg.QuietMode != queue.QuietModeDiscard {
439 t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
440 }
441 })
442
443 t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
444 cfg, err := Load(Options{
445 File: writeTOML(t, `
446 quiet_hours = "18:00-08:00"
447 quiet_mode = "discard"
448
449 [queue]
450 rate_limit = "10s"
451 allow_high_burst = false
452 `),
453 Getenv: env(map[string]string{
454 "TALK_RATE_LIMIT": "1s",
455 "TALK_ALLOW_HIGH_BURST": "true",
456 "TALK_QUIET_HOURS": "off",
457 "TALK_QUIET_MODE": "toast",
458 }),
459 })
460 if err != nil {
461 t.Fatalf("Load: %v", err)
462 }
463 if cfg.Queue.RateLimit != time.Second {
464 t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
465 }
466 if !cfg.Queue.AllowHighBurst {
467 t.Error("Queue.AllowHighBurst = false, want true aus Env")
468 }
469 if cfg.QuietHours != "" {
470 t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
471 }
472 if cfg.QuietMode != queue.QuietModeToast {
473 t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
474 }
475 })
476
477 t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
478 cases := []struct {
479 name string
480 toml string
481 envs map[string]string
482 want string
483 }{
484 {
485 name: "TALK_RATE_LIMIT Müll",
486 envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
487 want: "TALK_RATE_LIMIT",
488 },
489 {
490 name: "TALK_RATE_LIMIT negativ",
491 envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
492 want: "TALK_RATE_LIMIT",
493 },
494 {
495 name: "rate_limit Müll in Datei",
496 toml: "[queue]\nrate_limit = \"müll\"\n",
497 want: "rate_limit",
498 },
499 {
500 name: "TALK_ALLOW_HIGH_BURST Müll",
501 envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
502 want: "TALK_ALLOW_HIGH_BURST",
503 },
504 {
505 name: "quiet_hours Müll in Datei",
506 toml: "quiet_hours = \"abends\"\n",
507 want: "quiet_hours",
508 },
509 {
510 name: "TALK_QUIET_HOURS Müll",
511 envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
512 want: "TALK_QUIET_HOURS",
513 },
514 {
515 name: "TALK_QUIET_MODE Müll",
516 envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
517 want: "TALK_QUIET_MODE",
518 },
519 }
520 for _, tc := range cases {
521 t.Run(tc.name, func(t *testing.T) {
522 file := fehlendeDatei(t)
523 if tc.toml != "" {
524 file = writeTOML(t, tc.toml)
525 }
526 _, err := Load(Options{File: file, Getenv: env(tc.envs)})
527 if err == nil || !strings.Contains(err.Error(), tc.want) {
528 t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
529 }
530 })
531 }
532 })
533 }
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, dazu den persistenten Mute-Stat . vor, dazu den persistenten Mute-Stat
. e . e
4 // (Datei mute im Config-Dir) und die 4 // (Datei mute im Config-Dir). Seit T
. Rate-Limit-Struktur. Das Rate-Limit . ask 05 unter dem Anti-Spam-Gesetz
5 // ist als Struktur vorhanden, aber n 5 // (rate.go/quiet.go): Rate-Limit — j
. och nicht angebunden — Wiring und . ede gestartete Ansage bucht das Fenst
. . er;
6 // Ruhezeiten folgen in Task 05. 6 // low über dem Budget verfällt, norm
. . al parkt bis zum Slot, high darf mit
. 7 // Burst-Recht verstoßen — und Ruheze
. . iten, die still verwerfen oder durch
. 8 // einen Toast ersetzen. now:true bri
. . cht bewusst aus beiden aus.
7 package queue 9 package queue
internal/queue/queue.go --- 1/4 --- Go
92 type Speaker func(ctx context.Conte 92 type Speaker func(ctx context.Conte
.. xt, item Item) error .. xt, item Item) error
93 93
94 // Queue ist die serialisierte Ansa 94 // Queue ist die serialisierte Ansa
.. ge-Queue des Daemons: genau eine An .. ge-Queue des Daemons: genau eine An
.. sage .. sage
95 // gleichzeitig, Reihenfolge now > 95 // gleichzeitig, Reihenfolge now >
.. high > normal > low (FIFO je Level) .. high > normal > low (FIFO je Level)
.. . .. —
.. 96 // seit Task 05 unter dem Anti-Spam
.. .. -Gesetz (Laws): normal parkt bis zu
.. .. m
.. 97 // Rate-Limit-Slot, low wird über d
.. .. em Budget verworfen, high darf mit
.. 98 // Burst-Recht verstoßen, now brich
.. .. t bewusst aus, und Ruhezeiten verwe
.. .. rfen
.. 99 // bzw. toasten statt Audio.
96 // 100 //
97 // Die Synchronisation ist bewusst 101 // Die Synchronisation ist bewusst
.. simpel und offensichtlich korrekt s ... simpel und offensichtlich korrekt s
.. tatt ... tatt
98 // clever: EIN Mutex + EIN Cond sch 102 // clever: EIN Mutex + EIN Cond sch
.. ützen alles (Items, aktuelles Item, ... ützen alles (Items, aktuelles Item,
99 // Closed-Flag, cancelCur). Der Wor 103 // Closed-Flag, cancelCur, Slot-Wec
.. ker ist der einzige, der Items entn ... ker). Der Worker ist der einzige, d
.. immt; ... er
100 // pop und das Setzen von current/c 104 // Items entnimmt; take und das Set
... ancelCur geschehen unter demselben ... zen von current/cancelCur geschehen
... Lock, ... unter
101 // damit ein Item nie zwischen „war 105 // demselben Lock, damit ein Item n
... tet" und „läuft" der Cancelierung ... ie zwischen „wartet" und „läuft" de
... ... r
102 // entwischen kann. Cancel/Close ru 106 // Cancelierung entwischen kann. Ca
... fen cancelCur unter dem Lock ab — d ... ncel/Close rufen cancelCur unter de
... er ... m Lock
103 // Worker bemerkt die Abbrechung in 107 // ab — der Worker bemerkt die Abbr
... seiner speak-Funktion über ctx. ... echung in seiner speak-Funktion übe
... ... r ctx.
... 108 // Geparkte Items bleiben in der Wa
... ... rteschlange liegen; ein Slot-Wecker
... 109 // (armWakeLocked) broadcastet zum
... ... Fenster-Ablauf, damit der Worker au
... ... ch ohne
... 110 // neuen Enqueue aufwacht — die Wai
... ... t-Bedingung prüft jedes Mal die Ges
... ... etze
... 111 // neu, verlorene Signale sind dadu
... ... rch harmlos.
104 type Queue struct { 112 type Queue struct {
... 113 laws Laws
... 114 limit *RateLimiter
105 speak Speaker 115 speak Speaker
106 log io.Writer 116 log io.Writer
107 117
108 mu sync.Mutex 118 mu sync.Mutex
109 notEmpty *sync.Cond // Signal: 119 notEmpty *sync.Cond // Signal:
... neues Item; Broadcast: Close ... neues Item; Broadcast: Close/Slot
... ... frei
110 items []Item 120 items []Item
111 current *Item 121 current *Item
112 cancelCur context.CancelFunc 122 cancelCur context.CancelFunc
113 seq uint64 123 seq uint64
114 closed bool 124 closed bool
... 125 wake *time.Timer // weck
... ... t den geparkten Worker zum Slot-Ter
... ... min
... 126 wakeAt time.Time // Term
... ... in des laufenden Weckers
115 done chan struct{} // gesc 127 done chan struct{} // gesc
... hlossen, wenn der Worker-Loop endet ... hlossen, wenn der Worker-Loop endet
116 } 128 }
117 129
118 // New startet die Queue mit ihrem 130 // New startet die Queue mit ihrem
... Worker-Loop (eine Ansage gleichzeit ... Worker-Loop (eine Ansage gleichzeit
... ig). ... ig).
... 131 // laws trägt die Anti-Spam-Gesetze
... ... (Nullwert = alles erlaubt; der Dae
... ... mon
119 // log ist optional (nil = still) u 132 // liefert die Config-Gesetze). log
... nd empfängt die Lebenslauf-Zeilen d ... ist optional (nil = still) und emp
... er ... fängt
120 // Items. Close beendet den Loop un 133 // die Lebenslauf-Zeilen der Items.
... d wartet auf sein Ende. ... Close beendet den Loop und wartet
... ... auf
... 134 // sein Ende.
121 func New(speak Speaker, log io.Writ 135 func New(laws Laws, speak Speaker,
... er) *Queue { ... log io.Writer) *Queue {
122 q := &Queue{speak: speak, log: 136 q := &Queue{
... log, done: make(chan struct{})} ...
... 137 laws: laws,
... 138 limit: &RateLimiter{Interva
... ... l: laws.RateLimit},
... 139 speak: speak,
... 140 log: log,
... 141 done: make(chan struct{}),
... 142 }
123 q.notEmpty = sync.NewCond(&q.mu 143 q.notEmpty = sync.NewCond(&q.mu
... ) ... )
124 go q.loop() 144 go q.loop()
125 return q 145 return q
126 } 146 }
127 147
128 // Enqueue stellt item in die Queue 148 // Enqueue stellt item in die Warte
... und liefert dessen ID (bei leerer ... schlange und liefert die Aufnahme-
... ID neu ...
... 149 // Entscheidung: Aufgenommene trage
... ... n ihre ID (bei leerer ID neu vergeb
... ... en:
... 150 // kurz und zufällig). Ruhezeit-Ver
... ... wurf/Toast und low über dem Fenster
... ... -Budget
... 151 // werden schon beim Eintritt entsc
... ... hieden (die 202-Antwort von POST /s
... ... peak
... 152 // nennt den Grund); alles andere e
... ... ntscheidet der Worker, wenn das Ite
... ... m
129 // vergeben: kurz und zufällig). Na 153 // fällig ist. Nach Close ist Enque
... ch Close ist Enqueue ein Fehler. ... ue ein Fehler.
130 func (q *Queue) Enqueue(item Item) 154 func (q *Queue) Enqueue(item Item)
... (string, error) { ... (Admission, error) {
131 q.mu.Lock() 155 q.mu.Lock()
132 if q.closed { 156 if q.closed {
133 q.mu.Unlock() 157 q.mu.Unlock()
134 return "", ErrClosed 158 return Admission{}, ErrClos
... ... ed
135 } 159 }
136 if item.Priority == "" { 160 if item.Priority == "" {
137 item.Priority = PriorityNor 161 item.Priority = PriorityNor
... mal ... mal
138 } 162 }
... 163 switch q.laws.decide(item, time
... ... .Now(), q.limit.Allow()) {
... 164 case verdictDiscardQuiet:
... 165 q.mu.Unlock()
... 166 q.logf("queue: ansage (%q,
... ... priorität %s) verworfen — ruhezeit
... ... %s aktiv (modus discard)",
... 167 item.Text, item.Priorit
... ... y, q.laws.Quiet)
... 168 return Admission{Discarded:
... ... DiscardQuietHours}, nil
... 169 case verdictToast:
... 170 q.mu.Unlock()
... 171 q.logf("queue: ansage (%q,
... ... priorität %s) durch toast ersetzt —
... ... ruhezeit %s aktiv (modus toast)",
... 172 item.Text, item.Priorit
... ... y, q.laws.Quiet)
... 173 return Admission{Discarded:
... ... DiscardQuietHours, Toast: true}, n
... ... il
... 174 case verdictDiscardRate:
... 175 q.mu.Unlock()
... 176 q.logf("queue: ansage (%q,
... ... priorität low) verworfen — rate-lim
... ... it %s",
... 177 item.Text, q.laws.RateL
... ... imit)
... 178 return Admission{Discarded:
... ... DiscardRateLimit}, nil
... 179 }
139 q.seq++ 180 q.seq++
140 item.seq = q.seq 181 item.seq = q.seq
141 if item.ID == "" { 182 if item.ID == "" {
internal/queue/queue.go --- 2/4 --- Go
144 185 q.items = append(q.items, item)
145 186 q.mu.Unlock()
146 187 q.notEmpty.Signal()
147 188 return Admission{ID: item.ID}, nil
148 189 }
149 190
150 191 // Pending liefert eine Momentaufnahme der wartenden Items in
internal/queue/queue.go --- 3/4 --- Go
208 if q.cancelCur != nil { 249 if q.cancelCur != nil {
209 q.cancelCur() 250 q.cancelCur()
210 } 251 }
... 252 if q.wake != nil {
... 253 q.wake.Stop()
... 254 q.wake = nil
... 255 }
211 q.mu.Unlock() 256 q.mu.Unlock()
212 q.notEmpty.Broadcast() 257 q.notEmpty.Broadcast()
213 <-q.done 258 <-q.done
214 } 259 }
... 260
... 261 // LawState liefert die /status-Sic
... ... ht auf die Anti-Spam-Gesetze: das
... 262 // konfigurierte Rate-Limit mit dem
... ... nächsten freien Slot (Nullzeit = f
... ... rei
... 263 // oder unbegrenzt) und das Ruhezei
... ... t-Fenster mit seinem Ende, wenn es
... 264 // gerade aktiv ist.
... 265 func (q *Queue) LawState() LawState
... ... {
... 266 now := time.Now()
... 267 return LawState{
... 268 RateLimit: q.laws.Rate
... ... Limit,
... 269 AllowHighBurst: q.laws.Allo
... ... wHighBurst,
... 270 NextSlot: q.limit.Nex
... ... t(),
... 271 Quiet: q.laws.Quie
... ... t,
... 272 QuietActive: q.laws.Quie
... ... t.Active(now),
... 273 QuietMode: q.laws.Quie
... ... tMode,
... 274 QuietUntil: q.laws.Quie
... ... t.Until(now),
... 275 }
... 276 }
215 277
216 // loop ist der Worker: wartet auf 278 // loop ist der Worker: wartet auf
... Items und spielt sie einzeln ab. Na ... fällige Items und spielt sie einzel
... ch ... n ab.
... 279 // „Fällig" heißt seit Task 05 auch
... ... gesetzkonform: Geparkte schlafen b
... ... is zum
... 280 // Rate-Limit-Slot (armWakeLocked w
... ... eckt), low- und Ruhezeit-Verwürfe l
... ... aufen
... 281 // ohne Audio durchs Protokoll, now
... ... und high-burst spielen sofort.
217 // Close (oder wenn Close während e 282 // Nach Close (oder wenn Close währ
... iner Ansage zuschlägt) beendet er s ... end einer Ansage zuschlägt) beendet
... ich, ... er
218 // sobald die laufende Ansage zurüc 283 // sich, sobald die laufende Ansage
... kgekehrt ist. ... zurückgekehrt ist.
219 func (q *Queue) loop() { 284 func (q *Queue) loop() {
220 defer close(q.done) 285 defer close(q.done)
221 for { 286 for {
222 q.mu.Lock() 287 q.mu.Lock()
223 for len(q.items) == 0 && !q 288 for !q.closed && !q.readyLo
... .closed { ... cked() {
... 289 if len(q.items) > 0 {
... 290 // Bestes Item park
... ... t auf den Slot: Wecker zum Termin a
... ... rmieren.
... 291 q.armWakeLocked(q.l
... ... imit.Next())
... 292 }
224 q.notEmpty.Wait() 293 q.notEmpty.Wait()
225 } 294 }
226 if q.closed { 295 if q.closed {
227 q.mu.Unlock() 296 q.mu.Unlock()
228 return 297 return
229 } 298 }
230 item := q.popLocked() 299 item, v := q.takeLocked()
... 300 if v == verdictWait {
... 301 // Kurz ohne Fortschrit
... ... t geweckt (z. B. verlorenes Signal)
... ... :
... 302 // wieder schlafen lege
... ... n. Kann nach readyLocked eigentlich
... ... nie
... 303 // eintreten — sicherhe
... ... itshalber geparkt statt gedreht.
... 304 q.mu.Unlock()
... 305 continue
... 306 }
... 307 if v == verdictPlay {
... 308 // Fenster buchen, BEVO
... ... R gesprochen wird: Sobald das Item
... ... als
... 309 // laufend gilt, steht
... ... der nächste Slot-Termin fest.
... 310 q.limit.Reserve()
231 ctx, cancel := context.With 311 ctx, cancel := context.
... Cancel(context.Background()) ... WithCancel(context.Background())
232 q.current = &item 312 q.current = &item
233 q.cancelCur = cancel 313 q.cancelCur = cancel
internal/queue/queue.go --- 4/4 --- Go
252 default: 332 default:
253 q.logf("queue: ansage % 333 q.logf("queue: ansa
... s fertig nach %s", item.ID, dur.Rou ... ge %s fertig nach %s", item.ID, dur
... nd(time.Millisecond)) ... .Round(time.Millisecond))
254 } 334 }
... 335 continue
... 336 }
... 337 q.mu.Unlock()
... 338 switch v {
... 339 case verdictDiscardRate:
... 340 q.logf("queue: ansage %
... ... s (%q, priorität low) verworfen — r
... ... ate-limit %s",
... 341 item.ID, item.Text,
... ... q.laws.RateLimit)
... 342 case verdictDiscardQuiet:
... 343 q.logf("queue: ansage %
... ... s (%q) verworfen — ruhezeit %s akti
... ... v (modus discard)",
... 344 item.ID, item.Text,
... ... q.laws.Quiet)
... 345 case verdictToast:
... 346 q.logf("queue: ansage %
... ... s (%q) durch toast ersetzt — ruheze
... ... it %s aktiv (modus toast)",
... 347 item.ID, item.Text,
... ... q.laws.Quiet)
... 348 }
255 } 349 }
256 } 350 }
... 351
... 352 // readyLocked meldet, ob der Worke
... ... r arbeiten kann (mu muss gehalten
... 353 // werden): geschlossen immer; sons
... ... t nur, wenn das beste Item nicht bl
... ... oß
... 354 // auf seinen Rate-Limit-Slot parkt
... ... . Verwürfe zählen als Arbeit — sie
... 355 // laufen ohne Audio, aber mit Prot
... ... okoll durch den Worker.
... 356 func (q *Queue) readyLocked() bool
... ... {
... 357 if q.closed || len(q.items) ==
... ... 0 {
... 358 return q.closed
... 359 }
... 360 return q.verdictLocked(q.items[
... ... q.bestIndexLocked()]) != verdictWai
... ... t
... 361 }
257 362
258 // popLocked entnimmt das als nächs 363 // takeLocked entnimmt das als näch
... tes fällige Item (mu muss gehalten ... stes fällige Item, wenn es spielbar
... werden). ... oder
... 364 // entscheidbar ist (mu muss gehalt
... ... en werden). Bei verdictWait wird NI
... ... CHT
... 365 // entnommen — das Item bleibt gepa
... ... rkt, bis der Slot frei ist (oder ei
... ... n
... 366 // besseres Item vorgezogen wird).
259 func (q *Queue) popLocked() Item { 367 func (q *Queue) takeLocked() (Item,
... ... verdict) {
260 best := 0 368 best := q.bestIndexLocked()
261 for i := 1; i < len(q.items); i ...
... ++ { ...
262 if itemBefore(q.items[i], q 369 item := q.items[best]
... .items[best]) { ...
263 best = i ...
264 } ...
265 } ...
266 item := q.items[best] 370 v := q.verdictLocked(item)
... 371 if v != verdictWait {
267 q.items = append(q.items[:best] 372 q.items = append(q.items[:b
... , q.items[best+1:]...) ... est], q.items[best+1:]...)
... 373 }
268 return item 374 return item, v
269 } 375 }
... 376
... 377 // bestIndexLocked liefert den Inde
... ... x des als nächstes fälligen Items
... 378 // (mu muss gehalten werden).
... 379 func (q *Queue) bestIndexLocked() i
... ... nt {
... 380 best := 0
... 381 for i := 1; i < len(q.items); i
... ... ++ {
... 382 if itemBefore(q.items[i], q
... ... .items[best]) {
... 383 best = i
... 384 }
... 385 }
... 386 return best
... 387 }
... 388
... 389 // verdictLocked wendet die Gesetze
... ... auf ein Item an (mu muss gehalten
... 390 // werden — die Lock-Ordnung Queue
... ... → RateLimiter bleibt gewahrt).
... 391 func (q *Queue) verdictLocked(item
... ... Item) verdict {
... 392 return q.laws.decide(item, time
... ... .Now(), q.limit.Allow())
... 393 }
... 394
... 395 // armWakeLocked armiert den Slot-W
... ... ecker auf until (mu muss gehalten
... 396 // werden): Er broadcastet die Cond
... ... zum Termin, damit der geparkte Wor
... ... ker
... 397 // auch ohne neuen Enqueue aufwacht
... ... . Ein bereits laufender Wecker mit
... 398 // früherem (oder gleichem) Termin
... ... reicht; sonst wird er ersetzt.
... 399 func (q *Queue) armWakeLocked(until
... ... time.Time) {
... 400 if q.wake != nil && !q.wakeAt.A
... ... fter(until) {
... 401 return
... 402 }
... 403 if q.wake != nil {
... 404 q.wake.Stop()
... 405 }
... 406 q.wakeAt = until
... 407 d := time.Until(until)
... 408 if d < 0 {
... 409 d = 0
... 410 }
... 411 q.wake = time.AfterFunc(d, func
... ... () {
... 412 q.mu.Lock()
... 413 q.wake = nil
... 414 q.mu.Unlock()
... 415 q.notEmpty.Broadcast()
... 416 })
... 417 }
270 418
271 // newID liefert eine kurze, kollis 419 // newID liefert eine kurze, kollis
... ionsarme ID (6 Hex-Zeichen). IDs mü ... ionsarme ID (6 Hex-Zeichen). IDs mü
... ssen ... ssen
272 // nur pro Daemon-Lauf eindeutig se 420 // nur pro Daemon-Lauf eindeutig se
... in (--wait-Polling); der Fallback i ... in (--wait-Polling); der Fallback i
... st ... st
internal/queue/queue_test.go --- 1/8 --- Go
67 // TestAbarbeitungsReihenfolge: now 67 // TestAbarbeitungsReihenfolge: now
.. vor high vor normal vor low, FIFO .. vor high vor normal vor low, FIFO
.. je .. je
68 // Level. Der erste Blocker besetzt 68 // Level. Der erste Blocker besetzt
.. den Worker; danach werden alle Lev .. den Worker; danach werden alle Lev
.. el .. el
69 // gleichzeitig eingereiht — die Ab 69 // gleichzeitig eingereiht — die Ab
.. arbeitung muss der Klasse folgen. .. arbeitung muss der Klasse folgen.
.. 70 // (Ohne Gesetze: Null-Laws = unbeg
.. .. renzt, keine Ruhezeit.)
70 func TestAbarbeitungsReihenfolge(t 71 func TestAbarbeitungsReihenfolge(t
.. *testing.T) { .. *testing.T) {
71 g := neueSpielerin() 72 g := neueSpielerin()
72 q := New(g.speak, nil) 73 q := New(Laws{}, g.speak, nil)
73 defer q.Close() 74 defer q.Close()
74 75
75 erster, err := q.Enqueue(Item{T 76 adm, err := q.Enqueue(Item{Text
.. ext: "blocker", Priority: PriorityN .. : "blocker", Priority: PriorityNorm
.. ormal}) .. al})
76 if err != nil { 77 if err != nil {
77 t.Fatal(err) 78 t.Fatal(err)
78 } 79 }
79 if erster == "" { 80 if adm.ID == "" || adm.Discarde
.. .. d != "" {
80 t.Fatal("Enqueue vergibt ke 81 t.Fatalf("Enqueue = %+v, wa
.. ine ID") .. nt Aufnahme mit ID", adm)
81 } 82 }
82 g.warteBis(t, 1) // Blocker läu 83 g.warteBis(t, 1) // Blocker läu
.. ft, Worker gebunden. .. ft, Worker gebunden.
83 84
internal/queue/queue_test.go --- 2/8 --- Go
111 112 // TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
112 113 func TestCurrentUndPending(t *testing.T) {
113 114 g := neueSpielerin()
114 115 q := New(Laws{}, g.speak, nil)
115 116 defer q.Close()
116 117
117 118 if _, ok := q.Current(); ok {
internal/queue/queue_test.go --- 3/8 --- Go
144 145 // spielbereit.
145 146 func TestCancel(t *testing.T) {
146 147 g := neueSpielerin()
147 148 q := New(Laws{}, g.speak, nil)
148 149 defer q.Close()
149 150
150 151 q.Enqueue(Item{Text: "blocker"})
internal/queue/queue_test.go --- 4/8 --- Go
165 } 166 }
166 167
167 // Queue nimmt wieder an und sp 168 // Queue nimmt wieder an und sp
... ielt: ... ielt:
168 id, err := q.Enqueue(Item{Text: 169 adm, err := q.Enqueue(Item{Text
... "danach"}) ... : "danach"})
169 if err != nil { 170 if err != nil {
170 t.Fatal(err) 171 t.Fatal(err)
171 } 172 }
172 g.step <- struct{}{} // gibt di 173 g.step <- struct{}{} // gibt di
... e wartende Ansage im Speaker frei ... e wartende Ansage im Speaker frei
173 gestartet := g.warteBis(t, 2) 174 gestartet := g.warteBis(t, 2)
174 if len(gestartet) != 1 || gesta 175 if len(gestartet) != 1 || gesta
... rtet[0].ID != id { ... rtet[0].ID != adm.ID {
175 t.Errorf("neue Ansage nicht 176 t.Errorf("neue Ansage nicht
... gestartet: %+v", gestartet) ... gestartet: %+v", gestartet)
176 } 177 }
177 } 178 }
internal/queue/queue_test.go --- 5/8 --- Go
181 182 // Enqueue ein Fehler (und Close idempotent).
182 183 func TestClose(t *testing.T) {
183 184 g := neueSpielerin()
184 185 q := New(Laws{}, g.speak, nil)
185 186
186 187 q.Enqueue(Item{Text: "blocker"})
187 188 g.warteBis(t, 1)
internal/queue/queue_test.go --- 6/8 --- Go
202 // TestEnqueueVergibtIDs: IDs sind 203 // TestEnqueueVergibtIDs: IDs sind
... kurz und eindeutig. ... kurz und eindeutig.
203 func TestEnqueueVergibtIDs(t *testi 204 func TestEnqueueVergibtIDs(t *testi
... ng.T) { ... ng.T) {
204 g := neueSpielerin() 205 g := neueSpielerin()
205 q := New(g.speak, nil) 206 q := New(Laws{}, g.speak, nil)
206 defer q.Close() 207 defer q.Close()
207 208
208 seen := map[string]bool{} 209 seen := map[string]bool{}
209 for i := 0; i < 20; i++ { 210 for i := 0; i < 20; i++ {
210 id, err := q.Enqueue(Item{T 211 adm, err := q.Enqueue(Item{
... ext: "x"}) ... Text: "x"})
211 if err != nil { 212 if err != nil {
212 t.Fatal(err) 213 t.Fatal(err)
213 } 214 }
214 if len(id) < 4 || seen[id] 215 if len(adm.ID) < 4 || seen[
... { ... adm.ID] {
215 t.Errorf("ID %q zu kurz 216 t.Errorf("ID %q zu kurz
... oder doppelt", id) ... oder doppelt", adm.ID)
216 } 217 }
217 seen[id] = true 218 seen[adm.ID] = true
218 } 219 }
219 } 220 }
220 221
internal/queue/queue_test.go --- 7/8 --- Go
279 } 280 }
280 } 281 }
281 282
282 // TestRateLimiter: Struktur-Semant 283 // TestRateLimiter: Struktur-Semant
... ik (Task 05 verdrahtet sie in den L ... ik (seit Task 05 in den Loop verwir
... oop). ... ed).
283 func TestRateLimiter(t *testing.T) 284 func TestRateLimiter(t *testing.T)
... { ... {
284 // Unbegrenzt (Task-04-Default) 285 // Unbegrenzt (Task-04-Default)
... : Interval 0 erlaubt immer. ... : Interval 0 erlaubt immer.
285 unlimited := &RateLimiter{} 286 unlimited := &RateLimiter{}
internal/queue/queue_test.go --- 8/8 --- Go
308 309 }
309 310 }
311
312 // fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
313 // (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
314 func fensterUm(t time.Time) string {
315 return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
316 }
317
318 // warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
319 // gesteuerte Speaker allein sieht sie nicht).
320 func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
321 t.Helper()
322 deadline := time.Now().Add(timeout)
323 for !cond() {
324 if time.Now().After(deadline) {
325 t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
326 }
327 time.Sleep(5 * time.Millisecond)
328 }
329 }
330
331 // TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
332 // erste Ansage sofort; low über dem Budget wird schon beim Eintritt
333 // verworfen (Admission nennt den Grund), normal parkt und spielt nach
334 // Fenster-Ablauf.
335 func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
336 g := neueSpielerin()
337 q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
338 defer q.Close()
339
340 if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
341 t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
342 }
343 g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
344
345 adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
346 if err != nil {
347 t.Fatal(err)
348 }
349 if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
350 t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
351 }
352 if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
353 t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
354 }
355
356 adm, err = q.Enqueue(Item{Text: "zweite"})
357 if err != nil || adm.ID == "" || adm.Discarded != "" {
358 t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
359 }
360
361 // Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
362 warteAuf(t, time.Second, func() bool {
363 for _, it := range q.Pending() {
364 if it.Text == "zweite" {
365 return true
366 }
367 }
368 return false
369 }, "geparkte normale Ansage in Pending")
370
371 close(g.step) // erste beenden — zweite muss weiter parken …
372 g.keineWeitere(t)
373 // … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
374 gestartet := g.warteBis(t, 2)
375 if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
376 t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
377 }
378 // low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
379 g.keineWeitere(t)
380 }
381
382 // TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
383 // laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
384 // wie normal (kein Verstoß).
385 func TestHighBurstEinUndAus(t *testing.T) {
386 g := neueSpielerin()
387 q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
388
389 q.Enqueue(Item{Text: "erste"})
390 g.warteBis(t, 1)
391 if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
392 t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
393 }
394 g.step <- struct{}{} // erste beenden
395 gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
396 if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
397 t.Errorf("burst = %+v, will dringend sofort", gestartet)
398 }
399 q.Close()
400
401 g2 := neueSpielerin()
402 q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
403 defer q2.Close()
404
405 q2.Enqueue(Item{Text: "erste"})
406 g2.warteBis(t, 1) // erste läuft — Fenster gebucht
407 if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
408 t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
409 }
410 g2.step <- struct{}{} // erste beenden
411 g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
412 }
413
414 // TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
415 // der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
416 func TestNowBypassesRateLimit(t *testing.T) {
417 g := neueSpielerin()
418 q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
419 defer q.Close()
420
421 q.Enqueue(Item{Text: "erste"})
422 g.warteBis(t, 1) // Fenster gebucht (10 s)
423
424 if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
425 t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
426 }
427 g.step <- struct{}{}
428 gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
429 if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
430 t.Errorf("now = %+v, will sofort", gestartet)
431 }
432
433 // Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
434 if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
435 t.Fatalf("normal nach now = %+v (%v)", adm, err)
436 }
437 close(g.step)
438 g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
439 }
440
441 // TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
442 // (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
443 // now bricht bewusst aus.
444 func TestRuhezeitDiscardUndToast(t *testing.T) {
445 fenster := fensterUm(time.Now())
446
447 g := neueSpielerin()
448 q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
449 defer q.Close()
450
451 adm, err := q.Enqueue(Item{Text: "normal"})
452 if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
453 t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
454 }
455 if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
456 t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
457 }
458 adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
459 if err != nil || adm.ID == "" || adm.Discarded != "" {
460 t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
461 }
462 gestartet := g.warteBis(t, 1)
463 if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
464 t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
465 }
466 g.keineWeitere(t)
467
468 g2 := neueSpielerin()
469 q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
470 defer q2.Close()
471
472 adm, err = q2.Enqueue(Item{Text: "normal"})
473 if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
474 t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
475 }
476 g2.keineWeitere(t)
477 }
478
479 // mustQuiet parst ein Ruhefenster oder bricht den Test ab.
480 func mustQuiet(t *testing.T, fenster string) QuietHours {
481 t.Helper()
482 q, err := ParseQuietHours(fenster)
483 if err != nil {
484 t.Fatal(err)
485 }
486 return q
487 }
488
489 // TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
490 // liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
491 func TestLawState(t *testing.T) {
492 g := neueSpielerin()
493 q := New(Laws{}, g.speak, nil)
494 st := q.LawState()
495 if !st.NextSlot.IsZero() || st.RateLimit != 0 {
496 t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
497 }
498 q.Close()
499
500 fenster := mustQuiet(t, "18:00-08:00")
501 q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
502 defer q2.Close()
503 st2 := q2.LawState()
504 if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
505 t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
506 }
507 if st2.Quiet.String() != "18:00-08:00" || st2.QuietMode != QuietModeToast {
508 t.Errorf("Ruhezeit in LawState = %q/%q", st2.Quiet, st2.QuietMode)
509 }
510
511 q2.Enqueue(Item{Text: "erste"})
512 g.warteBis(t, 1)
513 if next := q2.LawState().NextSlot; !next.After(time.Now()) {
514 t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
515 }
516 g.step <- struct{}{}
517 }
internal/queue/quiet.go --- Go
1 package queue
2
3 import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8 )
9
10 // Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
11 const (
12 QuietModeDiscard = "discard"
13 QuietModeToast = "toast"
14 )
15
16 // clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
17 type clockTime struct{ hour, minute int }
18
19 // minutes liefert Minuten seit Mitternacht.
20 func (c clockTime) minutes() int { return c.hour*60 + c.minute }
21
22 // String rendert die Uhrzeit als "HH:MM".
23 func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
24
25 // QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
26 // (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
27 // rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
28 type QuietHours struct {
29 Start, End clockTime
30 }
31
32 // String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
33 func (q QuietHours) String() string {
34 if q.Start == q.End {
35 return ""
36 }
37 return q.Start.String() + "-" + q.End.String()
38 }
39
40 // ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
41 // 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
42 // (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
43 // Leerstring ist das deaktivierte Fenster (kein Fehler).
44 func ParseQuietHours(s string) (QuietHours, error) {
45 if s == "" {
46 return QuietHours{}, nil
47 }
48 startS, endS, ok := strings.Cut(s, "-")
49 if !ok {
50 return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
51 }
52 start, err := parseClock(startS)
53 if err != nil {
54 return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
55 }
56 end, err := parseClock(endS)
57 if err != nil {
58 return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
59 }
60 return QuietHours{Start: start, End: end}, nil
61 }
62
63 // parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
64 func parseClock(s string) (clockTime, error) {
65 hourS, minuteS, ok := strings.Cut(s, ":")
66 if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
67 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
68 }
69 hour, err := strconv.Atoi(hourS)
70 if err != nil {
71 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
72 }
73 minute, err := strconv.Atoi(minuteS)
74 if err != nil {
75 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
76 }
77 if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
78 return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
79 }
80 return clockTime{hour: hour, minute: minute}, nil
81 }
82
83 // Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
84 // Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
85 // Nachtfenster (Start > End) überspannen Mitternacht.
86 func (q QuietHours) Active(t time.Time) bool {
87 if q.Start == q.End {
88 return false
89 }
90 m := t.Hour()*60 + t.Minute()
91 s, e := q.Start.minutes(), q.End.minutes()
92 if s < e {
93 return m >= s && m < e
94 }
95 return m >= s || m < e
96 }
97
98 // Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
99 // wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
100 func (q QuietHours) Until(t time.Time) time.Time {
101 if !q.Active(t) {
102 return time.Time{}
103 }
104 end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
105 if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
106 end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
107 }
108 return end
109 }
110
internal/queue/quiet_test.go --- Go
1 package queue
2
3 import (
4 "testing"
5 "time"
6 )
7
8 // lokal baut eine lokale Test-Zeit (Stunde/Minute reichen — Ruhefenster
9 // arbeiten auf Minuten-Auflösung).
10 func lokal(hour, minute int) time.Time {
11 return time.Date(2026, 10, 18, hour, minute, 0, 0, time.Local)
12 }
13
14 // TestParseQuietHours: Format HH:MM-HH:MM (überspannt Mitternacht),
15 // klare Fehler für Müll.
16 func TestParseQuietHours(t *testing.T) {
17 cases := []struct {
18 in string
19 start clockTime
20 end clockTime
21 bad bool
22 }{
23 {in: "18:00-08:00", start: clockTime{18, 0}, end: clockTime{8, 0}},
24 {in: "9:30-17:45", start: clockTime{9, 30}, end: clockTime{17, 45}},
25 {in: "22:00-06:00", start: clockTime{22, 0}, end: clockTime{6, 0}},
26 {in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // deaktiviert, aber gültig
27 {in: ""}, // Leerstring = deaktiviert, kein Fehler
28 {in: "18:00", bad: true},
29 {in: "18:00-", bad: true},
30 {in: "-08:00", bad: true},
31 {in: "24:00-08:00", bad: true},
32 {in: "18:60-08:00", bad: true},
33 {in: "1800-0800", bad: true},
34 {in: "ab:00-08:00", bad: true},
35 }
36 for _, tc := range cases {
37 q, err := ParseQuietHours(tc.in)
38 if tc.bad {
39 if err == nil {
40 t.Errorf("ParseQuietHours(%q) = %+v, want Fehler", tc.in, q)
41 }
42 continue
43 }
44 if err != nil {
45 t.Errorf("ParseQuietHours(%q): unerwarteter Fehler %v", tc.in, err)
46 continue
47 }
48 if q.Start != tc.start || q.End != tc.end {
49 t.Errorf("ParseQuietHours(%q) = %v-%v, want %v-%v", tc.in, q.Start, q.End, tc.start, tc.end)
50 }
51 }
52 }
53
54 // TestQuietHoursActive: Tagfenster, Nachtfenster über Mitternacht und der
55 // deaktivierte Nullwert über alle Grenzen (Start inklusiv, End exklusiv).
56 func TestQuietHoursActive(t *testing.T) {
57 cases := []struct {
58 fenster string
59 hour, minute int
60 want bool
61 }{
62 // Tagfenster 09:00-17:00
63 {"09:00-17:00", 8, 59, false},
64 {"09:00-17:00", 9, 0, true}, // Start inklusiv
65 {"09:00-17:00", 12, 30, true},
66 {"09:00-17:00", 16, 59, true},
67 {"09:00-17:00", 17, 0, false}, // End exklusiv
68 {"09:00-17:00", 23, 0, false},
69 // Nachtfenster 18:00-08:00 (überspannt Mitternacht)
70 {"18:00-08:00", 17, 59, false},
71 {"18:00-08:00", 18, 0, true},
72 {"18:00-08:00", 23, 59, true},
73 {"18:00-08:00", 0, 0, true}, // über Mitternacht
74 {"18:00-08:00", 7, 59, true},
75 {"18:00-08:00", 8, 0, false}, // End exklusiv
76 {"18:00-08:00", 12, 0, false},
77 // Zweites Nachtfenster
78 {"22:00-06:00", 3, 0, true},
79 {"22:00-06:00", 21, 59, false},
80 // Start == End: deaktiviert
81 {"08:00-08:00", 8, 0, false},
82 {"08:00-08:00", 20, 0, false},
83 }
84 for _, tc := range cases {
85 q := mustQuiet(t, tc.fenster)
86 if got := q.Active(lokal(tc.hour, tc.minute)); got != tc.want {
87 t.Errorf("%s um %02d:%02d: Active = %v, want %v", tc.fenster, tc.hour, tc.minute, got, tc.want)
88 }
89 }
90 }
91
92 // TestQuietHoursUntil: Fenster-Ende (heute bzw. morgen bei Nachtfenstern),
93 // Nullzeit wenn inaktiv.
94 func TestQuietHoursUntil(t *testing.T) {
95 // Tagfenster: Ende heute.
96 q := mustQuiet(t, "09:00-17:00")
97 if want := lokal(17, 0); !q.Until(lokal(12, 0)).Equal(want) {
98 t.Errorf("Until(12:00) = %v, want %v (heute 17:00)", q.Until(lokal(12, 0)), want)
99 }
100 if !q.Until(lokal(3, 0)).IsZero() {
101 t.Error("Until außerhalb des Fensters: want Nullzeit")
102 }
103
104 // Nachtfenster: ab 18:00 ist das Ende morgen früh.
105 n := mustQuiet(t, "18:00-08:00")
106 if want := lokal(0, 0).Add(32 * time.Hour); !n.Until(lokal(20, 0)).Equal(want) {
107 t.Errorf("Until(20:00) = %v, want %v (morgen 08:00)", n.Until(lokal(20, 0)), want)
108 }
109 // Nach Mitternacht (00:30) ist das Ende heute 08:00.
110 if want := lokal(8, 0); !n.Until(lokal(0, 30)).Equal(want) {
111 t.Errorf("Until(00:30) = %v, want %v (heute 08:00)", n.Until(lokal(0, 30)), want)
112 }
113 }
114
115 // TestQuietHoursStringRoundtrip: String liefert die konfigurierte Form
116 // zurück und parst identisch; der Nullwert rendert leer.
117 func TestQuietHoursStringRoundtrip(t *testing.T) {
118 for _, s := range []string{"18:00-08:00", "09:00-17:00", "22:00-06:00"} {
119 q := mustQuiet(t, s)
120 if q.String() != s {
121 t.Errorf("String() = %q, want %q", q.String(), s)
122 }
123 q2 := mustQuiet(t, q.String())
124 if q2 != q {
125 t.Errorf("Roundtrip von %q: %+v ≠ %+v", s, q2, q)
126 }
127 }
128 if (QuietHours{}).String() != "" {
129 t.Error("Nullwert-String nicht leer")
130 }
131 }
132
internal/queue/rate.go --- 1/3 --- Go
5 "time" 5 "time"
6 ) 6 )
. 7
. 8 // Verwurfs-Gründe im HTTP-Vertrag
. . von POST /speak
. 9 // (202 {"id":"","discarded":"…"}).
. 10 const (
. 11 DiscardRateLimit = "rate_limit
. .. " // priority=low über dem Fenster
. .. -Budget
. 12 DiscardQuietHours = "quiet_hour
. .. s" // Ruhezeit aktiv (discard oder
. .. toast)
. 13 )
7 14
8 // RateLimiter ist die Struktur für 15 // RateLimiter ist das Zeitfenster
. das Daemon-Gesetz gegen Ansage-Spa .. des Daemon-Gesetzes gegen Ansage-Sp
. m .. am:
9 // (Spec: Default ca. 1 Ansage / 30 16 // Spec-Default ca. 1 Ansage / 30 s
. s, konfigurierbar). .. (queue.rate_limit), 0 = unbegrenzt
. .. .
. 17 // Jede gestartete Ansage bucht das
. .. Fenster neu (Reserve) — auch eine
. .. per
. 18 // now oder high-burst ausgebrochen
. .. e: Das Fenster zählt Abspiel-Starts
. .. ,
. 19 // nicht deren Legitimität.
10 // 20 //
11 // SEAM FÜR TASK 05: Struktur und S 21 // Lock-Ordnung: RateLimiter.mu ist
.. emantik stehen und sind getestet, s .. immer die innere Sperre — Aufrufer
.. ind ..
12 // aber noch NICHT an den Worker-Lo 22 // halten beim Allow/Reserve/Next d
.. op angebunden — Task 04 erzeugt kei .. ie Queue-sperre (niemals umgekehrt)
.. nen .. .
13 // Limiter (unbegrenzt, so verlangt ..
.. es der Task-04-Brief: „Default une ..
.. ndlich ..
14 // in T04-Tests, dokumentiert"). Ta ..
.. sk 05 verdrahtet den Limiter (confi ..
.. g ..
15 // queue.rate_limit) in den Loop un ..
.. d ergänzt die prioritätsbewusste ..
16 // Entscheidung: low verwerfen, nor ..
.. mal parken, high konfigurierbar ver ..
.. stoßen ..
17 // (queue.allow_high_burst) — dafür ..
.. ist Next() bereits Teil der Strukt ..
.. ur. ..
18 type RateLimiter struct { 23 type RateLimiter struct {
19 // Interval ist der Mindestabst 24 // Interval ist der Mindestabst
.. and zwischen zwei Ansage-Starts. .. and zwischen zwei Ansage-Starten.
20 // 0 oder negativ = unbegrenzt 25 // 0 oder negativ = unbegrenzt.
.. (Task-04-Default). ..
21 Interval time.Duration 26 Interval time.Duration
22 27
23 mu sync.Mutex 28 mu sync.Mutex
internal/queue/rate.go --- 2/3 --- Go
34 return time.Since(l.last) >= l. 39 return time.Since(l.last) >= l.
.. Interval .. Interval
35 } 40 }
36 41
37 // Reserve bucht einen Ansage-Slot 42 // Reserve bucht einen Ansage-Slot
.. (direkt nach Allow aufrufen). .. (direkt nach der Play-Entscheidung
.. 43 // aufrufen, vor dem eigentlichen A
.. .. bspielen — auch fehlgeschlagene Ans
.. .. agen
.. 44 // verbrauchen das Fenster: Ein kap
.. .. utter Provider darf nicht zum Masch
.. .. inengewehr
.. 45 // werden).
38 func (l *RateLimiter) Reserve() { 46 func (l *RateLimiter) Reserve() {
39 if l == nil || l.Interval <= 0 47 if l == nil || l.Interval <= 0
.. { .. {
40 return 48 return
internal/queue/rate.go --- 3/3 --- Go
44 l.last = time.Now() 52 l.last = time.Now()
45 } 53 }
46 54
47 // Next liefert, wann der nächste A 55 // Next liefert, wann der nächste A
.. nsage-Slot frei wird (Nullzeit, wen .. nsage-Slot frei wird — Nullzeit, we
.. n .. nn
48 // Allow sofort true liefern würde) 56 // Allow sofort true liefern würde
.. — Basis für das Parken wartender I .. (unbegrenzt oder Fenster abgelaufen
.. tems .. ).
49 // in Task 05. 57 // Basis für das Parken wartender I
.. .. tems und den Status-Endpoint.
50 func (l *RateLimiter) Next() time.T 58 func (l *RateLimiter) Next() time.T
.. ime { .. ime {
51 if l == nil || l.Interval <= 0 59 if l == nil || l.Interval <= 0
.. { .. {
52 return time.Time{} 60 return time.Time{}
53 } 61 }
54 l.mu.Lock() 62 l.mu.Lock()
55 defer l.mu.Unlock() 63 defer l.mu.Unlock()
.. 64 if l.last.IsZero() {
.. 65 return time.Time{}
.. 66 }
56 return l.last.Add(l.Interval) 67 return l.last.Add(l.Interval)
57 } 68 }
69
70 // Laws sind die Anti-Spam-Gesetze
.. .. des Daemons (Task 05): Rate-Limit m
.. .. it
71 // verkonfigurierbarem Verstoß-Rech
.. .. t für high, plus Ruhezeiten. Der Nu
.. .. llwert
72 // erlaubt alles (unbegrenzt, kein
.. .. Ruhefenster) — der Daemon liefert d
.. .. ie
73 // Gesetze aus der Config; Default
.. .. siehe internal/config.Defaults.
74 type Laws struct {
75 // RateLimit ist der Mindestabs
.. .. tand zwischen zwei Ansage-Starten
76 // (0 = unbegrenzt).
77 RateLimit time.Duration
78
79 // AllowHighBurst erlaubt prior
.. .. ity=high, das Rate-Limit bewusst zu
80 // verstoßen (Default true). Oh
.. .. ne Burst-Recht wartet high wie norm
.. .. al.
81 // Die Ruhezeit gilt davon unbe
.. .. rührt auch für high.
82 AllowHighBurst bool
83
84 // Quiet ist das Ruhezeit-Fenst
.. .. er (lokale Zeit; Nullwert = deaktiv
.. .. iert).
85 Quiet QuietHours
86
87 // QuietMode bestimmt das Verha
.. .. lten in der Ruhezeit: "discard" ver
.. .. wirft
88 // still, "toast" ersetzt Audio
.. .. durch eine Benachrichtigung — aktu
.. .. ell
89 // eine Protokoll-Zeile über de
.. .. n Notification-Pfad des Loggers; de
.. .. r
90 // echte OS-Toast folgt mit der
.. .. Notification-Anbindung (dokumentie
.. .. rte
91 // Einschränkung, siehe Task-05
.. .. -Report).
92 QuietMode string
93 }
94
95 // verdict ist die Gesetzes-Entsche
.. .. idung über ein fälliges Item.
96 type verdict int
97
98 const (
99 verdictPlay verdict = i
.. .. ota // abspielen
100 verdictWait
.. ... // parken bis zum freien Slot
101 verdictDiscardRate
.. ... // verwerfen: low über dem Budg
.. ... et
102 verdictDiscardQuiet
.. ... // verwerfen: Ruhezeit (discard
.. ... )
103 verdictToast
.. ... // Toast statt Audio (Ruhezeit)
104 )
105
106 // decide wendet die Gesetze auf ei
.. ... n fälliges Item an (rein, ohne Zust
.. ... and):
107 // slotFree sagt, ob der Rate-Limit
.. ... -Slot gerade frei ist. Rangordnung:
108 //
109 // 1. now:true bricht bewusst aus
.. ... — überspringt Rate-Limit UND Ruheze
.. ... it
110 // (dokumentierter Ausbruch für
.. ... Dringendes; startet trotzdem ein n
.. ... eues
111 // Rate-Fenster).
112 // 2. Ruhezeit: discard verwirft,
.. ... toast ersetzt — gilt auch für high,
113 // denn Burst ist ein Rate-Rech
.. ... t, kein Ruhe-Recht.
114 // 3. Rate-Limit: freier Slot spie
.. ... lt; high spielt mit Burst-Recht; lo
.. ... w
115 // wird verworfen; normal (und
.. ... high ohne Recht) parken bis zum Slo
.. ... t.
116 func (l Laws) decide(item Item, now
.. ... time.Time, slotFree bool) verdict
.. ... {
117 if item.Now {
118 return verdictPlay
119 }
120 if l.Quiet.Active(now) {
121 if l.QuietMode == QuietMode
.. ... Toast {
122 return verdictToast
123 }
124 return verdictDiscardQuiet
125 }
126 if slotFree {
127 return verdictPlay
128 }
129 if item.Priority == PriorityHig
.. ... h && l.AllowHighBurst {
130 return verdictPlay
131 }
132 if item.Priority == PriorityLow
.. ... {
133 return verdictDiscardRate
134 }
135 return verdictWait
136 }
137
138 // Admission ist das Ergebnis von E
.. ... nqueue: Aufgenommene tragen ihre ID
.. ... ,
139 // verworfene nennen den Grund (die
.. ... 202-Antwort von POST /speak führt
.. ... ihn).
140 type Admission struct {
141 // ID der aufgenommenen Ansage
.. ... (leer, wenn verworfen).
142 ID string
143
144 // Discarded ist der Verwurfs-G
.. ... rund (DiscardRateLimit bzw.
145 // DiscardQuietHours); leer, we
.. ... nn die Ansage aufgenommen wurde.
146 Discarded string
147
148 // Toast meldet, dass die Ansag
.. ... e in der Ruhezeit durch eine
149 // Benachrichtigung ersetzt wur
.. ... de (quiet_mode = "toast").
150 Toast bool
151 }
152
153 // LawState ist die /status-Sicht a
.. ... uf die Anti-Spam-Gesetze: konfiguri
.. ... ertes
154 // Rate-Limit mit dem nächsten frei
.. ... en Slot sowie das Ruhezeit-Fenster.
155 type LawState struct {
156 RateLimit time.Duration
157 AllowHighBurst bool
158 NextSlot time.Time // Nul
.. ... lzeit = Slot frei (oder unbegrenzt)
159 Quiet QuietHours
160 QuietActive bool
161 QuietMode string
162 QuietUntil time.Time // Nul
.. ... lzeit = Ruhezeit inaktiv
163 }
test/harness_test.go --- 1/3 --- Go
188 188 t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
189 189 }
... 190
... 191 // arrivedCount zählt die angekommten Synthese-Requests (auch abgebrochene)
... 192 // — Beobachtungspunkt für „genau N spielen, der Rest parkt/verfällt".
... 193 func (f *fakeProvider) arrivedCount() int {
... 194 f.mu.Lock()
... 195 defer f.mu.Unlock()
... 196 return len(f.arrived)
... 197 }
... 198
... 199 // assertNoFurtherArrivals prüft, dass innerhalb der Frist kein weiterer
... 200 // Synthese-Request ankommt (geparkte/verworfene Ansagen bleiben stumm).
... 201 func (f *fakeProvider) assertNoFurtherArrivals(t *testing.T, wait time.Duration) {
... 202 t.Helper()
... 203 n := f.arrivedCount()
... 204 time.Sleep(wait)
... 205 if got := f.arrivedCount(); got != n {
... 206 t.Fatalf("%d zusätzliche Ankünfte (want keine) — Treffer: %+v", got-n, f.snapshot())
... 207 }
... 208 }
190 209
191 210 // waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
192 211 func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
test/harness_test.go --- 2/3 --- Go
296 315 // damit unter <root>/talk), audio.sink=null und der Fake-Provider als
297 316 // ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
298 317 // wird sofort abgelehnt).
... 318 //
... 319 // TALK_RATE_LIMIT=0s ist der Basis-Default der Prozessgrenzen-Tests: Die
... 320 // Task-04-Semantik-Tests (Serialisierung, Priorität, …) enqueueen dicht
... 321 // hintereinander und erwarten sofortiges Durchspielen — das Gesetz
... 322 // (Default 30 s) würde sie parken. Die Gesetz-Tests in ratelimit_test.go
... 323 // überschreiben den Wert bewusst per extra.
299 324 func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
300 325 env := make([]string, 0, 32)
301 326 for _, kv := range os.Environ() {
test/harness_test.go --- 3/3 --- Go
312 337 "TALK_ELEVENLABS_ENDPOINT": fakeURL,
313 338 "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
314 339 "TALK_PORT": strconv.Itoa(port),
... 340 "TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
315 341 }
316 342 for k, v := range extra {
317 343 set[k] = v
test/ratelimit_test.go --- Go
1 package test
2
3 // Prozessgrenzen-Tests für das Anti-Spam-Gesetz (Task 05): Rate-Limit
4 // (low verworfen, normal geparkt, high-burst, now-Ausbruch) und Ruhezeiten
5 // (discard/toast) — beobachtet als Black Box über HTTP/CLI, Fake-Provider
6 // und Daemon-Protokoll. Der Harness stellt dafür TALK_RATE_LIMIT=0s als
7 // Basis; diese Tests stellen das Gesetz bewusst per extra an.
8
9 import (
10 "encoding/json"
11 "net/http"
12 "strings"
13 "testing"
14 "time"
15 )
16
17 // fensterUm legt ein Ruhezeit-Fenster um die aktuelle lokale Zeit (±1 Min,
18 // Minute-Auflösung reicht) — so ist das Fenster unabhängig von der Uhrzeit
19 // des Testlaufs sicher aktiv.
20 func fensterUm() string {
21 now := time.Now()
22 return now.Add(-time.Minute).Format("15:04") + "-" + now.Add(time.Minute).Format("15:04")
23 }
24
25 // lawStatus ist die Gesetz-Sicht aus GET /status.
26 type lawStatus struct {
27 RateLimit struct {
28 Interval string `json:"interval"`
29 AllowHighBurst bool `json:"allow_high_burst"`
30 NextSlot *string `json:"next_slot"`
31 } `json:"rate_limit"`
32 QuietHours struct {
33 Window string `json:"window"`
34 Active bool `json:"active"`
35 Mode string `json:"mode"`
36 Until *string `json:"until"`
37 } `json:"quiet_hours"`
38 }
39
40 // getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder.
41 func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
42 t.Helper()
43 resp, err := http.Get(d.baseURL + "/status")
44 if err != nil {
45 t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String())
46 }
47 defer resp.Body.Close()
48 var st lawStatus
49 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
50 t.Fatalf("GET /status dekodieren: %v", err)
51 }
52 return st
53 }
54
55 // discardedOf liest das discarded-Feld einer /speak-Antwort-Map — fehlt der
56 // Schlüssel (JSON-Decode in map[string]any liefert nil), ist er leer.
57 func discardedOf(m map[string]any) string {
58 if v, ok := m["discarded"].(string); ok {
59 return v
60 }
61 return ""
62 }
63
64 // TestRateLimitGesetz (Abnahmekriterium): kurzes Rate-Limit (1/s) — von
65 // fünf Ansagen spielt genau die erste sofort; low verfällt noch beim
66 // Eintritt (202 nennt den Grund), normal parkt und spielt nach dem Fenster.
67 func TestRateLimitGesetz(t *testing.T) {
68 root := t.TempDir()
69 fake := newFakeProvider(t, 100*time.Millisecond)
70 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
71 env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
72
73 code, erste := speak(t, d, `{"text":"erste ansage"}`)
74 if code != 202 || erste["id"] == "" {
75 t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, erste)
76 }
77 fake.waitArrived(t, 1) // erste läuft — das Fenster (1 s) ist gebucht.
78
79 // CLI-Beobachtung: talk say --priority low meldet den Verwurf plain.
80 res := runTalk(t, env, "say", "--priority", "low", "spam eins")
81 if res.code != 0 {
82 t.Fatalf("talk say --priority low: Exit %d, stderr: %s", res.code, res.stderr)
83 }
84 if res.stdout != "discarded: rate_limit\n" {
85 t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n")
86 }
87
88 // Zwei weitere low-Ansagen verfallen ebenso beim Eintritt.
89 for _, text := range []string{"spam zwei", "spam drei"} {
90 code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`)
91 if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" {
92 t.Fatalf("low %s: Status=%d Body=%v, want 202 mit leerer id und discarded=rate_limit", text, code, m)
93 }
94 }
95
96 // Normal wird aufgenommen …
97 code, geparkte := speak(t, d, `{"text":"geparkte ansage"}`)
98 if code != 202 || geparkte["id"] == "" || discardedOf(geparkte) != "" {
99 t.Fatalf("normal: Status=%d Body=%v, want 202 mit id", code, geparkte)
100 }
101
102 // … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in
103 // der Zukunft.
104 st := getStatus(t, d)
105 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != geparkte["id"] {
106 t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue)
107 }
108 law := getLawStatus(t, d)
109 if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
110 t.Errorf("rate_limit = %+v, want Interval 1s mit Burst-Recht", law.RateLimit)
111 }
112 if law.RateLimit.NextSlot == nil {
113 t.Errorf("next_slot = null während geparkter Ansage, want Termin")
114 }
115
116 // Genau eine ist bislang angekommen: die geparkte spielt NICHT vor
117 // dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich).
118 fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
119
120 // Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot.
121 hits := fake.waitCompleted(t, 2)
122 if hits[1].Text != "geparkte ansage" {
123 t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text)
124 }
125 if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
126 t.Errorf("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap)
127 }
128 waitStatusIdle(t, d, 3*time.Second)
129 }
130
131 // TestHighBurstProzess: mit Burst-Recht (Default) startet high trotz
132 // vollem Fensters sofort nach der laufenden Ansage; ohne Recht parkt high
133 // wie normal. (Das spätere Abspielen nach langem Fenster deckt der
134 // Park-Test oben mit kurzem Fenster.)
135 func TestHighBurstProzess(t *testing.T) {
136 t.Run("burst an (Default)", func(t *testing.T) {
137 root := t.TempDir()
138 fake := newFakeProvider(t, 50*time.Millisecond)
139 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"})
140
141 speak(t, d, `{"text":"erste"}`)
142 fake.waitArrived(t, 1) // Fenster für 8 s gebucht.
143
144 code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
145 if code != 202 || m["id"] == "" {
146 t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m)
147 }
148
149 // high startet, sobald die erste fertig ist — bewusst innerhalb
150 // des Fensters (Verstoß mit Recht).
151 hits := fake.waitCompleted(t, 2)
152 if hits[1].Text != "dringend" {
153 t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text)
154 }
155 if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
156 t.Errorf("high startete nach %v — Burst greift nicht (Fenster 8 s)", gap)
157 }
158 waitStatusIdle(t, d, 3*time.Second)
159 })
160
161 t.Run("burst aus", func(t *testing.T) {
162 root := t.TempDir()
163 fake := newFakeProvider(t, 0)
164 d := startDaemon(t, root, fake.srv.URL, map[string]string{
165 "TALK_RATE_LIMIT": "8s",
166 "TALK_ALLOW_HIGH_BURST": "false",
167 })
168
169 speak(t, d, `{"text":"erste"}`)
170 fake.waitArrived(t, 1)
171
172 code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
173 if code != 202 || m["id"] == "" {
174 t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m)
175 }
176
177 // high bleibt geparkt: keine zweite Ankunft, Queue zeigt es.
178 fake.assertNoFurtherArrivals(t, 700*time.Millisecond)
179 st := getStatus(t, d)
180 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] {
181 t.Fatalf("queue = %v (%d), will geparktes high", st.QueueIDs, st.Queue)
182 }
183 if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
184 t.Error("allow_high_burst = true, want false")
185 }
186
187 // Aufräumen über cancel — und der Daemon bleibt gesund (now
188 // umgeht das weiter laufende 8-s-Fenster).
189 code, cm := postPath(t, d, "/cancel")
190 if code != 200 || cm["cancelled"] != float64(1) {
191 t.Fatalf("cancel: %d %v, want 1 geparkte Ansage", code, cm)
192 }
193 speak(t, d, `{"text":"danach","now":true}`)
194 hits := fake.waitCompleted(t, 2)
195 if hits[1].Text != "danach" {
196 t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text)
197 }
198 })
199 }
200
201 // TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es
202 // spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit.
203 func TestNowBypassesRateUndRuhe(t *testing.T) {
204 root := t.TempDir()
205 fake := newFakeProvider(t, 0)
206 d := startDaemon(t, root, fake.srv.URL, map[string]string{
207 "TALK_RATE_LIMIT": "30s",
208 "TALK_QUIET_HOURS": fensterUm(),
209 })
210
211 // Normal verfällt in der Ruhezeit (hier: still).
212 code, m := speak(t, d, `{"text":"ruhig bitte"}`)
213 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
214 t.Fatalf("normal in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m)
215 }
216
217 // now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze.
218 code, m = speak(t, d, `{"text":"notfall","now":true}`)
219 if code != 202 || m["id"] == "" || discardedOf(m) != "" {
220 t.Fatalf("now: Status=%d Body=%v, want 202 mit id", code, m)
221 }
222 hits := fake.waitCompleted(t, 1)
223 if hits[0].Text != "notfall" {
224 t.Errorf("Treffer = %q, will notfall", hits[0].Text)
225 }
226 fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
227
228 if law := getLawStatus(t, d); !law.QuietHours.Active {
229 t.Errorf("quiet_hours.active = false, want true (Fenster um jetzt): %+v", law.QuietHours)
230 }
231 }
232
233 // TestRuhezeitDiscard: aktives Fenster verwirft normal UND high still
234 // (Burst ist ein Rate-Recht, kein Ruhe-Recht) — ohne Provider-Kontakt,
235 // mit Verwurf-Zeile im Daemon-Protokoll und Fenster-Ende in /status.
236 func TestRuhezeitDiscard(t *testing.T) {
237 root := t.TempDir()
238 fake := newFakeProvider(t, 0)
239 fenster := fensterUm()
240 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster})
241
242 for _, body := range []string{
243 `{"text":"abends eins"}`,
244 `{"text":"abends dringend","priority":"high"}`,
245 } {
246 code, m := speak(t, d, body)
247 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil {
248 t.Fatalf("%s: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", body, code, m)
249 }
250 }
251 fake.assertNoHits(t, 400*time.Millisecond)
252
253 log := d.stderr.String()
254 if !strings.Contains(log, "ruhezeit") || !strings.Contains(log, "verworfen") {
255 t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log)
256 }
257
258 law := getLawStatus(t, d)
259 if law.QuietHours.Window != fenster || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
260 t.Errorf("quiet_hours = %+v, want Fenster %q aktiv im Modus discard", law.QuietHours, fenster)
261 }
262 if law.QuietHours.Until == nil {
263 t.Error("quiet_hours.until = null im aktiven Fenster, want Termin")
264 }
265 }
266
267 // TestRuhezeitToast: quiet_mode=toast ersetzt Audio durch eine
268 // Benachrichtigung — derzeit eine Daemon-Protokoll-Zeile über den
269 // Notification-Pfad (dokumentierte Einschränkung bis zur OS-Anbindung).
270 func TestRuhezeitToast(t *testing.T) {
271 root := t.TempDir()
272 fake := newFakeProvider(t, 0)
273 d := startDaemon(t, root, fake.srv.URL, map[string]string{
274 "TALK_QUIET_HOURS": fensterUm(),
275 "TALK_QUIET_MODE": "toast",
276 })
277
278 code, m := speak(t, d, `{"text":"abend kurz"}`)
279 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
280 t.Fatalf("toast: Status=%d Body=%v, want 202 mit leerer id, discarded=quiet_hours und toast=true", code, m)
281 }
282 fake.assertNoHits(t, 300*time.Millisecond)
283
284 if log := d.stderr.String(); !strings.Contains(log, "toast") {
285 t.Errorf("Daemon-Log will Toast-Zeile (Notification-Pfad), hat:\n%s", log)
286 }
287 }
288
289 // TestStatusGesetzeFelder (Abnahmekriterium): /status und `talk status`
290 // zeigen Rate-Limit- und Ruhezeit-State — Defaults im Leerlauf, gebuchter
291 // Slot nach einer Ansage.
292 func TestStatusGesetzeFelder(t *testing.T) {
293 root := t.TempDir()
294 fake := newFakeProvider(t, 0)
295 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
296 env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
297
298 law := getLawStatus(t, d)
299 if law.RateLimit.Interval != "30s" || !law.RateLimit.AllowHighBurst || law.RateLimit.NextSlot != nil {
300 t.Errorf("rate_limit = %+v, want Default 30s, Burst an, Slot frei", law.RateLimit)
301 }
302 if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
303 t.Errorf("quiet_hours = %+v, want deaktiviert im Modus discard", law.QuietHours)
304 }
305
306 res := runTalk(t, env, "status")
307 if res.code != 0 {
308 t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
309 }
310 for _, want := range []string{
311 "rate-limit: 30s",
312 "high-burst erlaubt",
313 "ruhezeit: aus",
314 "modus discard",
315 } {
316 if !strings.Contains(res.stdout, want) {
317 t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
318 }
319 }
320
321 // Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile.
322 speak(t, d, `{"text":"einmal reicht"}`)
323 fake.waitArrived(t, 1)
324 if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
325 t.Error("next_slot = null nach abgespielter Ansage, want Termin")
326 }
327 res = runTalk(t, env, "status")
328 if res.code != 0 || !strings.Contains(res.stdout, "nächster slot") {
329 t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code)
330 }
331 }
332