# 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: `. `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.]`). 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 /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