refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d30bd95ad
commit
85fa64cfe6
95 files changed
+4707
-4854
No files matched your search
@@ -1,9 +1,10 @@
|
||||
// Package queue implementiert die Ansage-Queue des Daemons: serialisierte
|
||||
// Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
|
||||
// FIFO je Level), now-Items springen vor, dazu den persistenten Mute-State
|
||||
// (Datei mute im Config-Dir). Seit Task 05 unter dem Anti-Spam-Gesetz
|
||||
// (rate.go/quiet.go): Rate-Limit — jede gestartete Ansage bucht das Fenster;
|
||||
// low über dem Budget verfällt, normal parkt bis zum Slot, high darf mit
|
||||
// Burst-Recht verstoßen — und Ruhezeiten, die still verwerfen oder durch
|
||||
// einen Toast ersetzen. now:true bricht bewusst aus beiden aus.
|
||||
// Package queue is the daemon's announcement queue: serialized playback (one
|
||||
// announcement at a time), priorities (high > normal > low, FIFO per level),
|
||||
// now items that jump ahead, plus the persistent mute state (file "mute" in
|
||||
// the config directory). The anti-spam laws (rate.go, quiet.go) sit on top:
|
||||
// a rate limit where every started announcement books the window (low over
|
||||
// budget is discarded, normal parks until the slot, high may break it with
|
||||
// burst rights), quiet hours that discard silently or toast instead, a text
|
||||
// length cap and a maximum queue age. now:true deliberately breaks out of
|
||||
// all of them.
|
||||
package queue
|
||||
+50
-50
@@ -6,9 +6,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
|
||||
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
|
||||
// unangetastet; Runen zählen, nicht Bytes.
|
||||
// TestTruncateText: cut to max runes at a word boundary (not in the first
|
||||
// half), without dangling punctuation; short texts and max 0 stay untouched;
|
||||
// runes count, not bytes.
|
||||
func TestTruncateText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -17,13 +17,13 @@ func TestTruncateText(t *testing.T) {
|
||||
want string
|
||||
truncated bool
|
||||
}{
|
||||
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
|
||||
{"genau max bleibt", "abcde", 5, "abcde", false},
|
||||
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
|
||||
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
|
||||
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
|
||||
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
|
||||
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
|
||||
{"short stays", "Build done", 400, "Build done", false},
|
||||
{"exactly max stays", "abcde", 5, "abcde", false},
|
||||
{"unlimited", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
|
||||
{"word boundary", "The build is done and ready", 14, "The build is", true},
|
||||
{"punctuation dropped", "Done, but then there was more", 6, "Done", true},
|
||||
{"no boundary in second half → hard cut", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
|
||||
{"umlauts count as one rune", "Übergröße ärgert", 9, "Übergröße", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -32,83 +32,83 @@ func TestTruncateText(t *testing.T) {
|
||||
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
|
||||
}
|
||||
if c.max > 0 && len([]rune(got)) > c.max {
|
||||
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
|
||||
t.Errorf("result %q longer than %d runes", got, c.max)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
|
||||
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
|
||||
func TestEnqueueKuerztText(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestEnqueueTruncatesText: the MaxText law applies on admission; the
|
||||
// shortened version is spoken and Admission reports Truncated.
|
||||
func TestEnqueueTruncatesText(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{MaxText: 20}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
|
||||
adm, err := q.Enqueue(Item{Text: "This text is clearly longer than twenty characters"})
|
||||
if err != nil || adm.ID == "" || !adm.Truncated {
|
||||
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
|
||||
t.Fatalf("Enqueue = %+v, %v; want admitted + Truncated", adm, err)
|
||||
}
|
||||
items := g.warteBis(t, 1)
|
||||
if items[0].Text != "Dieser Text ist" {
|
||||
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
|
||||
items := g.waitUntil(t, 1)
|
||||
if items[0].Text != "This text is clearly" {
|
||||
t.Errorf("spoken = %q, want the shortened version", items[0].Text)
|
||||
}
|
||||
g.step <- struct{}{}
|
||||
|
||||
adm, _ = q.Enqueue(Item{Text: "kurz"})
|
||||
adm, _ = q.Enqueue(Item{Text: "short"})
|
||||
if adm.Truncated {
|
||||
t.Error("kurzer Text als gekürzt gemeldet")
|
||||
t.Error("short text reported as truncated")
|
||||
}
|
||||
g.warteBis(t, 2)
|
||||
g.waitUntil(t, 2)
|
||||
g.step <- struct{}{}
|
||||
}
|
||||
|
||||
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
|
||||
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
|
||||
// TestDecideExpired: an item that waited longer than MaxAge is discarded,
|
||||
// before quiet hours and rate limit but after now (now breaks out).
|
||||
func TestDecideExpired(t *testing.T) {
|
||||
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
|
||||
now := time.Now()
|
||||
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
|
||||
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
|
||||
old := Item{Text: "old", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
|
||||
fresh := Item{Text: "fresh", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
|
||||
|
||||
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
|
||||
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
|
||||
if v := laws.decide(old, now, true); v != verdictDiscardExpired {
|
||||
t.Errorf("old item with open slot = %v, want verdictDiscardExpired", v)
|
||||
}
|
||||
if v := laws.decide(frisch, now, true); v != verdictPlay {
|
||||
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
|
||||
if v := laws.decide(fresh, now, true); v != verdictPlay {
|
||||
t.Errorf("fresh item with open slot = %v, want verdictPlay", v)
|
||||
}
|
||||
if v := laws.decide(frisch, now, false); v != verdictWait {
|
||||
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
|
||||
if v := laws.decide(fresh, now, false); v != verdictWait {
|
||||
t.Errorf("fresh item without slot = %v, want verdictWait", v)
|
||||
}
|
||||
altNow := alt
|
||||
altNow.Now = true
|
||||
if v := laws.decide(altNow, now, false); v != verdictPlay {
|
||||
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
|
||||
oldNow := old
|
||||
oldNow.Now = true
|
||||
if v := laws.decide(oldNow, now, false); v != verdictPlay {
|
||||
t.Errorf("old now item = %v, want verdictPlay (now breaks out)", v)
|
||||
}
|
||||
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
|
||||
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
|
||||
if v := (Laws{}).decide(old, now, true); v != verdictPlay {
|
||||
t.Errorf("MaxAge 0 (unlimited) discards old item: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
|
||||
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
|
||||
// nennt max_age) und nicht mehr gesprochen.
|
||||
func TestGeparktesItemVerfaellt(t *testing.T) {
|
||||
// TestParkedItemExpires: a normal item parks for the rate-limit slot; if it
|
||||
// is older than MaxAge when it comes due, it is discarded (log names
|
||||
// max_age) and never spoken.
|
||||
func TestParkedItemExpires(t *testing.T) {
|
||||
logb := &syncLog{}
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
|
||||
g.warteBis(t, 1)
|
||||
q.Enqueue(Item{Text: "first"}) // books the window
|
||||
g.waitUntil(t, 1)
|
||||
g.step <- struct{}{}
|
||||
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
|
||||
adm, _ := q.Enqueue(Item{Text: "parks too long"}) // parks ~300 ms > MaxAge
|
||||
if adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
|
||||
t.Fatalf("nothing may expire on admission: %+v", adm)
|
||||
}
|
||||
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
|
||||
g.keineWeitere(t)
|
||||
g.noFurther(t)
|
||||
if !strings.Contains(logb.String(), adm.ID) {
|
||||
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
|
||||
t.Errorf("log does not name the expired announcement:\n%s", logb.String())
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -7,31 +7,27 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MuteFileName ist der Name der Mute-Datei im Config-Verzeichnis (bindender
|
||||
// Vertrag: „persistenten Mute-State toggeln (Datei mute im Config-Dir)").
|
||||
// MuteFileName is the mute file's name inside the config directory.
|
||||
const MuteFileName = "mute"
|
||||
|
||||
// ErrNoMutePath meldet einen Set-Versuch ohne konfigurierten Datei-Pfad
|
||||
// (Config-Dir nicht bestimmbar — der Daemon deaktiviert dann Mute mit einer
|
||||
// Start-Warnung statt zu scheitern).
|
||||
var ErrNoMutePath = errors.New("mute: kein Datei-Pfad konfiguriert (Config-Dir nicht bestimmbar)")
|
||||
// ErrNoMutePath reports a Set attempt without a configured file path (config
|
||||
// directory unknown; the daemon then runs without mute and warns at start).
|
||||
var ErrNoMutePath = errors.New("mute: no file path configured (config directory unknown)")
|
||||
|
||||
// MuteStore verwaltet den persistenten Mute-State als Datei im
|
||||
// Config-Verzeichnis: Inhalt „true" = stumm, alles andere (oder keine Datei)
|
||||
// = nicht stumm.
|
||||
// MuteStore manages the persistent mute state as a file in the config
|
||||
// directory: content "true" = muted, anything else (or no file) = not muted.
|
||||
//
|
||||
// Der Daemon liest die Datei bei jeder /speak-Anfrage neu — dadurch wirkt
|
||||
// auch ein im Degraded Mode (Daemon tot) von der CLI direkt geschriebener
|
||||
// State sofort, und der Zustand überlebt Daemon-Restarts.
|
||||
// The daemon re-reads the file on every /speak request, so a state written
|
||||
// by the CLI while no daemon runs takes effect immediately, and the state
|
||||
// survives daemon restarts.
|
||||
type MuteStore struct {
|
||||
// Path ist der vollständige Pfad der Mute-Datei. Leer (kein Config-Dir
|
||||
// bestimmbar): Muted() ist immer false, Set ein Fehler.
|
||||
// Path is the mute file's full path. Empty (no config directory): Muted
|
||||
// is always false and Set an error.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Muted meldet den persistenten Mute-State. Eine fehlende oder unlesbare
|
||||
// Datei heißt: nicht stumm (Fehlstart ohne Config-Dir wird nie „stumm aus
|
||||
// Versehen").
|
||||
// Muted reports the persistent mute state. A missing or unreadable file
|
||||
// means not muted: a broken setup must never be "accidentally silent".
|
||||
func (s MuteStore) Muted() bool {
|
||||
if s.Path == "" {
|
||||
return false
|
||||
@@ -43,9 +39,9 @@ func (s MuteStore) Muted() bool {
|
||||
return strings.TrimSpace(string(b)) == "true"
|
||||
}
|
||||
|
||||
// Set schreibt den Mute-State atomar (temporäre Datei + Rename, damit
|
||||
// Daemon und CLI nie eine halb geschriebene Datei lesen) und legt das
|
||||
// Config-Verzeichnis bei Bedarf an.
|
||||
// Set writes the mute state atomically (temp file + rename, so daemon and
|
||||
// CLI never read a half-written file) and creates the config directory if
|
||||
// needed.
|
||||
func (s MuteStore) Set(muted bool) error {
|
||||
if s.Path == "" {
|
||||
return ErrNoMutePath
|
||||
|
||||
+122
-128
@@ -12,18 +12,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
|
||||
// (low | normal | high; leer wird als normal behandelt).
|
||||
// Priority of a queue item; public contract of the HTTP API
|
||||
// (low | normal | high; empty is treated as normal).
|
||||
type Priority string
|
||||
|
||||
// Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
|
||||
// Allowed priority values (high > normal > low, FIFO per level).
|
||||
const (
|
||||
PriorityLow Priority = "low"
|
||||
PriorityNormal Priority = "normal"
|
||||
PriorityHigh Priority = "high"
|
||||
)
|
||||
|
||||
// Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
|
||||
// Valid reports whether p is an allowed priority value.
|
||||
func (p Priority) Valid() bool {
|
||||
switch p {
|
||||
case PriorityLow, PriorityNormal, PriorityHigh:
|
||||
@@ -33,43 +33,42 @@ func (p Priority) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
|
||||
var ErrClosed = errors.New("queue: bereits geschlossen")
|
||||
// ErrClosed reports an Enqueue attempt after Close (daemon shutting down).
|
||||
var ErrClosed = errors.New("queue: already closed")
|
||||
|
||||
// Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
|
||||
// HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
|
||||
// Item is an announcement in the queue. All fields come from the HTTP API
|
||||
// (POST /speak); ID and lifecycle belong to the queue.
|
||||
type Item struct {
|
||||
// ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
|
||||
// des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
|
||||
// ID is the short announcement identifier (returned by POST /speak; the
|
||||
// CLI polls it for --wait). Assigned by Enqueue when empty.
|
||||
ID string
|
||||
|
||||
// Text ist der Ansage-Text.
|
||||
// Text is the announcement text.
|
||||
Text string
|
||||
|
||||
// Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
|
||||
// durch den Daemon (langdetect).
|
||||
// Lang pins the language (de | en); empty = detected by the daemon.
|
||||
Lang string
|
||||
|
||||
// Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
|
||||
// = konfigurierte Default-Kette.
|
||||
// Provider pins the chain to one tier (--provider); empty = the
|
||||
// configured default chain.
|
||||
Provider string
|
||||
|
||||
// Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
|
||||
// Voice pins a voice ID (--voice); empty = voice mapping/defaults.
|
||||
Voice string
|
||||
|
||||
// Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
|
||||
// FIFO je Level).
|
||||
// Priority controls processing order (high > normal > low, FIFO per
|
||||
// level).
|
||||
Priority Priority
|
||||
|
||||
// Now lässt die Ansage vor alle wartenden Items springen (auch vor
|
||||
// high) — sie unterbricht aber nie die laufende Ansage.
|
||||
// Now lets the announcement jump ahead of every waiting item (including
|
||||
// high); it never interrupts the announcement that is playing.
|
||||
Now bool
|
||||
|
||||
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
|
||||
enqueued time.Time // Eintrittszeit (Basis für Laws.MaxAge)
|
||||
seq uint64 // enqueue order: FIFO tiebreaker within a level
|
||||
enqueued time.Time // admission time (basis for Laws.MaxAge)
|
||||
}
|
||||
|
||||
// rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
|
||||
// rank returns the processing class: now over high over normal over low.
|
||||
func (it Item) rank() int {
|
||||
if it.Now {
|
||||
return 3
|
||||
@@ -84,56 +83,53 @@ func (it Item) rank() int {
|
||||
}
|
||||
}
|
||||
|
||||
// Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
|
||||
// aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
|
||||
// Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
|
||||
// abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
|
||||
// heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
|
||||
// dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
|
||||
// Speaker plays an item. In the daemon: build the provider chain and call
|
||||
// Speak (synthesis with fallback plus playback through the audio sink). ctx
|
||||
// is cancelled when the announcement is cancelled (POST /cancel) or the
|
||||
// daemon shuts down; the chain then aborts its network and process work.
|
||||
// The returned error is logged, never passed to the API.
|
||||
type Speaker func(ctx context.Context, item Item) error
|
||||
|
||||
// Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
|
||||
// gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level) —
|
||||
// seit Task 05 unter dem Anti-Spam-Gesetz (Laws): normal parkt bis zum
|
||||
// Rate-Limit-Slot, low wird über dem Budget verworfen, high darf mit
|
||||
// Burst-Recht verstoßen, now bricht bewusst aus, und Ruhezeiten verwerfen
|
||||
// bzw. toasten statt Audio.
|
||||
// Queue is the daemon's serialized announcement queue: exactly one
|
||||
// announcement at a time, order now > high > normal > low (FIFO per level),
|
||||
// under the anti-spam laws: normal parks until the rate-limit slot, low
|
||||
// over budget is discarded, high may break the limit with burst rights, now
|
||||
// deliberately breaks out, quiet hours discard or toast instead of audio,
|
||||
// stale items expire.
|
||||
//
|
||||
// Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
|
||||
// clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
|
||||
// Closed-Flag, cancelCur, Slot-Wecker). Der Worker ist der einzige, der
|
||||
// Items entnimmt; take und das Setzen von current/cancelCur geschehen unter
|
||||
// demselben Lock, damit ein Item nie zwischen „wartet" und „läuft" der
|
||||
// Cancelierung entwischen kann. Cancel/Close rufen cancelCur unter dem Lock
|
||||
// ab — der Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
|
||||
// Geparkte Items bleiben in der Warteschlange liegen; ein Slot-Wecker
|
||||
// (armWakeLocked) broadcastet zum Fenster-Ablauf, damit der Worker auch ohne
|
||||
// neuen Enqueue aufwacht — die Wait-Bedingung prüft jedes Mal die Gesetze
|
||||
// neu, verlorene Signale sind dadurch harmlos.
|
||||
// Synchronization is deliberately simple and obviously correct rather than
|
||||
// clever: ONE mutex and ONE cond protect everything (items, current item,
|
||||
// closed flag, cancelCur, slot timer). The worker is the only one taking
|
||||
// items; take and setting current/cancelCur happen under the same lock, so
|
||||
// an item can never slip past cancellation between "waiting" and "playing".
|
||||
// Cancel/Close call cancelCur under the lock; the worker notices via ctx in
|
||||
// its speak function. Parked items stay in the queue; a slot timer
|
||||
// (armWakeLocked) broadcasts when the window elapses so the worker wakes
|
||||
// without a new Enqueue. The wait condition re-evaluates the laws every
|
||||
// time, so lost signals are harmless.
|
||||
type Queue struct {
|
||||
laws Laws
|
||||
limit *RateLimiter
|
||||
speak Speaker
|
||||
log io.Writer
|
||||
toast func(Item) // Ruhezeit-Toast (optional; gesetzt vor dem ersten Enqueue)
|
||||
toast func(Item) // quiet-hours toast (optional; set before the first Enqueue)
|
||||
|
||||
mu sync.Mutex
|
||||
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
|
||||
notEmpty *sync.Cond // Signal: new item; Broadcast: Close / slot open
|
||||
items []Item
|
||||
current *Item
|
||||
cancelCur context.CancelFunc
|
||||
seq uint64
|
||||
closed bool
|
||||
wake *time.Timer // weckt den geparkten Worker zum Slot-Termin
|
||||
wakeAt time.Time // Termin des laufenden Weckers
|
||||
done chan struct{} // geschlossen, wenn der Worker-Loop endet
|
||||
wake *time.Timer // wakes the parked worker at the slot time
|
||||
wakeAt time.Time // time of the armed timer
|
||||
done chan struct{} // closed when the worker loop ends
|
||||
}
|
||||
|
||||
// New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
|
||||
// laws trägt die Anti-Spam-Gesetze (Nullwert = alles erlaubt; der Daemon
|
||||
// liefert die Config-Gesetze). log ist optional (nil = still) und empfängt
|
||||
// die Lebenslauf-Zeilen der Items. Close beendet den Loop und wartet auf
|
||||
// sein Ende.
|
||||
// New starts the queue with its worker loop (one announcement at a time).
|
||||
// laws carries the anti-spam laws (zero value = everything allowed; the
|
||||
// daemon supplies the configured laws). log is optional (nil = silent) and
|
||||
// receives the items' lifecycle lines. Close ends the loop and waits for it.
|
||||
func New(laws Laws, speak Speaker, log io.Writer) *Queue {
|
||||
q := &Queue{
|
||||
laws: laws,
|
||||
@@ -147,24 +143,24 @@ func New(laws Laws, speak Speaker, log io.Writer) *Queue {
|
||||
return q
|
||||
}
|
||||
|
||||
// OnToast setzt die Benachrichtigung, die in der Ruhezeit mit quiet_mode =
|
||||
// "toast" statt Audio ausgelöst wird (Daemon: Desktop-Notification). Ohne
|
||||
// Setzen bleibt es bei der Protokollzeile. Vor dem ersten Enqueue setzen.
|
||||
// OnToast sets the notification fired during quiet hours with quiet_mode =
|
||||
// "toast" instead of audio (daemon: desktop notification). Unset, only the
|
||||
// log line remains. Set it before the first Enqueue.
|
||||
func (q *Queue) OnToast(fn func(Item)) { q.toast = fn }
|
||||
|
||||
// fireToast ruft die Toast-Benachrichtigung auf (nie unter dem Mutex).
|
||||
// fireToast calls the toast hook (never under the mutex).
|
||||
func (q *Queue) fireToast(item Item) {
|
||||
if q.toast != nil {
|
||||
q.toast(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
|
||||
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
|
||||
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
|
||||
// werden schon beim Eintritt entschieden (die 202-Antwort von POST /speak
|
||||
// nennt den Grund); alles andere entscheidet der Worker, wenn das Item
|
||||
// fällig ist. Nach Close ist Enqueue ein Fehler.
|
||||
// Enqueue puts item into the queue and returns the admission decision:
|
||||
// admitted items carry their ID (assigned when empty: short and random).
|
||||
// Quiet-hours discard/toast and low over the window budget are decided on
|
||||
// admission (the 202 response of POST /speak names the reason); everything
|
||||
// else is decided by the worker when the item comes due. After Close,
|
||||
// Enqueue is an error.
|
||||
func (q *Queue) Enqueue(item Item) (Admission, error) {
|
||||
q.mu.Lock()
|
||||
if q.closed {
|
||||
@@ -176,24 +172,24 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
|
||||
}
|
||||
var truncated bool
|
||||
if item.Text, truncated = truncateText(item.Text, q.laws.MaxText); truncated {
|
||||
defer q.logf("queue: ansage (%q …) auf %d Zeichen gekürzt (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
|
||||
defer q.logf("queue: announcement (%q …) truncated to %d characters (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
|
||||
}
|
||||
item.enqueued = time.Now()
|
||||
switch q.laws.decide(item, item.enqueued, q.limit.Allow()) {
|
||||
case verdictDiscardQuiet:
|
||||
q.mu.Unlock()
|
||||
q.logf("queue: ansage (%q, priorität %s) verworfen — ruhezeit %s aktiv (modus discard)",
|
||||
q.logf("queue: announcement (%q, priority %s) discarded: quiet hours %s active (mode discard)",
|
||||
item.Text, item.Priority, q.laws.Quiet)
|
||||
return Admission{Discarded: DiscardQuietHours}, nil
|
||||
case verdictToast:
|
||||
q.mu.Unlock()
|
||||
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
||||
q.logf("queue: announcement (%q, priority %s) replaced by toast: quiet hours %s active (mode toast)",
|
||||
item.Text, item.Priority, q.laws.Quiet)
|
||||
q.fireToast(item)
|
||||
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
|
||||
case verdictDiscardRate:
|
||||
q.mu.Unlock()
|
||||
q.logf("queue: ansage (%q, priorität low) verworfen — rate-limit %s",
|
||||
q.logf("queue: announcement (%q, priority low) discarded: rate limit %s",
|
||||
item.Text, q.laws.RateLimit)
|
||||
return Admission{Discarded: DiscardRateLimit}, nil
|
||||
}
|
||||
@@ -208,7 +204,7 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
|
||||
return Admission{ID: item.ID, Truncated: truncated}, nil
|
||||
}
|
||||
|
||||
// firstRunes liefert die ersten n Runen von s (Protokoll-Vorschau).
|
||||
// firstRunes returns the first n runes of s (log preview).
|
||||
func firstRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
@@ -217,8 +213,8 @@ func firstRunes(s string, n int) string {
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// Pending liefert eine Momentaufnahme der wartenden Items in
|
||||
// Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
|
||||
// Pending returns a snapshot of the waiting items in processing order (best
|
||||
// item first); basis for GET /status.
|
||||
func (q *Queue) Pending() []Item {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
@@ -228,8 +224,8 @@ func (q *Queue) Pending() []Item {
|
||||
return out
|
||||
}
|
||||
|
||||
// itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
|
||||
// innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
|
||||
// itemBefore reports whether a is processed before b: higher class first,
|
||||
// earlier enqueue order within the same class.
|
||||
func itemBefore(a, b Item) bool {
|
||||
if a.rank() != b.rank() {
|
||||
return a.rank() > b.rank()
|
||||
@@ -237,7 +233,7 @@ func itemBefore(a, b Item) bool {
|
||||
return a.seq < b.seq
|
||||
}
|
||||
|
||||
// Current liefert das gerade laufende Item (false: Queue ist idle).
|
||||
// Current returns the item that is playing (false: queue is idle).
|
||||
func (q *Queue) Current() (Item, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
@@ -247,8 +243,8 @@ func (q *Queue) Current() (Item, bool) {
|
||||
return *q.current, true
|
||||
}
|
||||
|
||||
// Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
|
||||
// die Zahl der abgebrochenen Ansagen (laufende + wartende).
|
||||
// Cancel aborts the playing announcement and empties the queue. It returns
|
||||
// the number of cancelled announcements (playing + waiting).
|
||||
func (q *Queue) Cancel() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
@@ -263,9 +259,9 @@ func (q *Queue) Cancel() int {
|
||||
return n
|
||||
}
|
||||
|
||||
// Close fährt die Queue herunter: wartende Items werden verworfen, die
|
||||
// laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
|
||||
// der Loop sicher beendet ist (idempotent).
|
||||
// Close shuts the queue down: waiting items are dropped, the playing
|
||||
// announcement is aborted, the worker loop ends. Close waits until the loop
|
||||
// has ended (idempotent).
|
||||
func (q *Queue) Close() {
|
||||
q.mu.Lock()
|
||||
if q.closed {
|
||||
@@ -287,10 +283,9 @@ func (q *Queue) Close() {
|
||||
<-q.done
|
||||
}
|
||||
|
||||
// LawState liefert die /status-Sicht auf die Anti-Spam-Gesetze: das
|
||||
// konfigurierte Rate-Limit mit dem nächsten freien Slot (Nullzeit = frei
|
||||
// oder unbegrenzt) und das Ruhezeit-Fenster mit seinem Ende, wenn es
|
||||
// gerade aktiv ist.
|
||||
// LawState returns the /status view of the laws: the configured rate limit
|
||||
// with the next open slot (zero = open or unlimited), the quiet window with
|
||||
// its end if active, and the length and age limits.
|
||||
func (q *Queue) LawState() LawState {
|
||||
now := time.Now()
|
||||
return LawState{
|
||||
@@ -306,19 +301,19 @@ func (q *Queue) LawState() LawState {
|
||||
}
|
||||
}
|
||||
|
||||
// loop ist der Worker: wartet auf fällige Items und spielt sie einzeln ab.
|
||||
// „Fällig" heißt seit Task 05 auch gesetzkonform: Geparkte schlafen bis zum
|
||||
// Rate-Limit-Slot (armWakeLocked weckt), low- und Ruhezeit-Verwürfe laufen
|
||||
// ohne Audio durchs Protokoll, now und high-burst spielen sofort.
|
||||
// Nach Close (oder wenn Close während einer Ansage zuschlägt) beendet er
|
||||
// sich, sobald die laufende Ansage zurückgekehrt ist.
|
||||
// loop is the worker: it waits for due items and plays them one by one.
|
||||
// "Due" includes the laws: parked items sleep until the rate-limit slot
|
||||
// (armWakeLocked wakes them), discards pass through the worker without
|
||||
// audio but with a log line, now and high burst play immediately. After
|
||||
// Close (also when Close hits during an announcement) it exits as soon as
|
||||
// the playing announcement has returned.
|
||||
func (q *Queue) loop() {
|
||||
defer close(q.done)
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for !q.closed && !q.readyLocked() {
|
||||
if len(q.items) > 0 {
|
||||
// Bestes Item parkt auf den Slot: Wecker zum Termin armieren.
|
||||
// The best item parks for its slot: arm the timer.
|
||||
q.armWakeLocked(q.limit.Next())
|
||||
}
|
||||
q.notEmpty.Wait()
|
||||
@@ -329,27 +324,28 @@ func (q *Queue) loop() {
|
||||
}
|
||||
item, v := q.takeLocked()
|
||||
if v == verdictWait {
|
||||
// Kurz ohne Fortschritt geweckt (z. B. verlorenes Signal):
|
||||
// wieder schlafen legen. Kann nach readyLocked eigentlich nie
|
||||
// eintreten — sicherheitshalber geparkt statt gedreht.
|
||||
// Woken without progress (for example a lost signal): go back to
|
||||
// sleep. Cannot really happen after readyLocked; parked rather
|
||||
// than spun, just in case.
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if v == verdictPlay {
|
||||
// Fenster buchen, BEVOR gesprochen wird: Sobald das Item als
|
||||
// laufend gilt, steht der nächste Slot-Termin fest.
|
||||
// Book the window BEFORE speaking: as soon as the item counts as
|
||||
// playing, the next slot time is fixed.
|
||||
q.limit.Reserve()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
q.current = &item
|
||||
q.cancelCur = cancel
|
||||
q.mu.Unlock()
|
||||
|
||||
q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
|
||||
q.logf("queue: announcement %s (%q, priority %s, now=%v) starting", item.ID, item.Text, item.Priority, item.Now)
|
||||
start := time.Now()
|
||||
err := q.speak(ctx, item)
|
||||
dur := time.Since(start)
|
||||
// Abbruch VOR dem eigenen cancel() ablesen — danach wäre ctx.Err()
|
||||
// immer gesetzt und jede fertige Ansage hieße „abgebrochen".
|
||||
// Read the cancellation BEFORE our own cancel(): afterwards
|
||||
// ctx.Err() is always set and every finished announcement would
|
||||
// read as cancelled.
|
||||
cancelled := ctx.Err() != nil
|
||||
cancel()
|
||||
|
||||
@@ -360,37 +356,37 @@ func (q *Queue) loop() {
|
||||
|
||||
switch {
|
||||
case cancelled:
|
||||
q.logf("queue: ansage %s abgebrochen", item.ID)
|
||||
q.logf("queue: announcement %s cancelled", item.ID)
|
||||
case err != nil:
|
||||
q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
|
||||
q.logf("queue: announcement %s failed after %s: %v", item.ID, dur.Round(time.Millisecond), err)
|
||||
default:
|
||||
q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
|
||||
q.logf("queue: announcement %s finished after %s", item.ID, dur.Round(time.Millisecond))
|
||||
}
|
||||
continue
|
||||
}
|
||||
q.mu.Unlock()
|
||||
switch v {
|
||||
case verdictDiscardRate:
|
||||
q.logf("queue: ansage %s (%q, priorität low) verworfen — rate-limit %s",
|
||||
q.logf("queue: announcement %s (%q, priority low) discarded: rate limit %s",
|
||||
item.ID, item.Text, q.laws.RateLimit)
|
||||
case verdictDiscardQuiet:
|
||||
q.logf("queue: ansage %s (%q) verworfen — ruhezeit %s aktiv (modus discard)",
|
||||
q.logf("queue: announcement %s (%q) discarded: quiet hours %s active (mode discard)",
|
||||
item.ID, item.Text, q.laws.Quiet)
|
||||
case verdictToast:
|
||||
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
||||
q.logf("queue: announcement %s (%q) replaced by toast: quiet hours %s active (mode toast)",
|
||||
item.ID, item.Text, q.laws.Quiet)
|
||||
q.fireToast(item)
|
||||
case verdictDiscardExpired:
|
||||
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
|
||||
q.logf("queue: announcement %s (%q) discarded: waited %s, older than max_age %s",
|
||||
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyLocked meldet, ob der Worker arbeiten kann (mu muss gehalten
|
||||
// werden): geschlossen immer; sonst nur, wenn das beste Item nicht bloß
|
||||
// auf seinen Rate-Limit-Slot parkt. Verwürfe zählen als Arbeit — sie
|
||||
// laufen ohne Audio, aber mit Protokoll durch den Worker.
|
||||
// readyLocked reports whether the worker has work (mu must be held): always
|
||||
// when closed; otherwise only when the best item is not merely parked for
|
||||
// its rate-limit slot. Discards count as work: they pass through the worker
|
||||
// without audio but with a log line.
|
||||
func (q *Queue) readyLocked() bool {
|
||||
if q.closed || len(q.items) == 0 {
|
||||
return q.closed
|
||||
@@ -398,10 +394,9 @@ func (q *Queue) readyLocked() bool {
|
||||
return q.verdictLocked(q.items[q.bestIndexLocked()]) != verdictWait
|
||||
}
|
||||
|
||||
// takeLocked entnimmt das als nächstes fällige Item, wenn es spielbar oder
|
||||
// entscheidbar ist (mu muss gehalten werden). Bei verdictWait wird NICHT
|
||||
// entnommen — das Item bleibt geparkt, bis der Slot frei ist (oder ein
|
||||
// besseres Item vorgezogen wird).
|
||||
// takeLocked removes the next due item when it is playable or decidable
|
||||
// (mu must be held). On verdictWait it is NOT removed: the item stays
|
||||
// parked until the slot opens (or a better item overtakes it).
|
||||
func (q *Queue) takeLocked() (Item, verdict) {
|
||||
best := q.bestIndexLocked()
|
||||
item := q.items[best]
|
||||
@@ -412,8 +407,7 @@ func (q *Queue) takeLocked() (Item, verdict) {
|
||||
return item, v
|
||||
}
|
||||
|
||||
// bestIndexLocked liefert den Index des als nächstes fälligen Items
|
||||
// (mu muss gehalten werden).
|
||||
// bestIndexLocked returns the index of the next due item (mu must be held).
|
||||
func (q *Queue) bestIndexLocked() int {
|
||||
best := 0
|
||||
for i := 1; i < len(q.items); i++ {
|
||||
@@ -424,16 +418,16 @@ func (q *Queue) bestIndexLocked() int {
|
||||
return best
|
||||
}
|
||||
|
||||
// verdictLocked wendet die Gesetze auf ein Item an (mu muss gehalten
|
||||
// werden — die Lock-Ordnung Queue → RateLimiter bleibt gewahrt).
|
||||
// verdictLocked applies the laws to an item (mu must be held; the lock order
|
||||
// queue → RateLimiter is preserved).
|
||||
func (q *Queue) verdictLocked(item Item) verdict {
|
||||
return q.laws.decide(item, time.Now(), q.limit.Allow())
|
||||
}
|
||||
|
||||
// armWakeLocked armiert den Slot-Wecker auf until (mu muss gehalten
|
||||
// werden): Er broadcastet die Cond zum Termin, damit der geparkte Worker
|
||||
// auch ohne neuen Enqueue aufwacht. Ein bereits laufender Wecker mit
|
||||
// früherem (oder gleichem) Termin reicht; sonst wird er ersetzt.
|
||||
// armWakeLocked arms the slot timer for until (mu must be held): it
|
||||
// broadcasts the cond at that time so the parked worker wakes without a new
|
||||
// Enqueue. A running timer with an earlier (or equal) time suffices;
|
||||
// otherwise it is replaced.
|
||||
func (q *Queue) armWakeLocked(until time.Time) {
|
||||
if q.wake != nil && !q.wakeAt.After(until) {
|
||||
return
|
||||
@@ -454,9 +448,9 @@ func (q *Queue) armWakeLocked(until time.Time) {
|
||||
})
|
||||
}
|
||||
|
||||
// newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
|
||||
// nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
|
||||
// praktisch unerreichbar, aber deterministisch sicher.
|
||||
// newID returns a short, collision-resistant ID (6 hex characters). IDs only
|
||||
// need to be unique per daemon run (--wait polling); the fallback is
|
||||
// practically unreachable but deterministic.
|
||||
func newID() string {
|
||||
var b [3]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
@@ -465,8 +459,8 @@ func newID() string {
|
||||
return fmt.Sprintf("id%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
|
||||
// unter dem Mutex gerufen (Aufrufer achten darauf).
|
||||
// logf writes a log line when a log is set. Never called under the mutex
|
||||
// (callers take care of that).
|
||||
func (q *Queue) logf(format string, args ...any) {
|
||||
if q.log != nil {
|
||||
fmt.Fprintf(q.log, format+"\n", args...)
|
||||
|
||||
+239
-233
@@ -11,23 +11,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// gesteuerteSpielerin ist ein testbarer Speaker: Jede Ansage meldet ihren
|
||||
// Start auf started (Kanal-Puffer: der Worker blockiert nie) und wartet
|
||||
// dann, bis der Test sie per step freigibt — so ist die Abarbeitung
|
||||
// vollständig deterministisch steuerbar. Ein abgebrochener ctx beendet das
|
||||
// Warten (Cancel-/Close-Pfade). Alle Felder werden nur vom Test gelesen;
|
||||
// started-Reihenfolge == Start-Reihenfolge.
|
||||
type gesteuerteSpielerin struct {
|
||||
// controlledSpeaker is a testable Speaker: every announcement reports its
|
||||
// start on started (buffered channel: the worker never blocks) and then
|
||||
// waits until the test releases it via step, so processing is fully
|
||||
// deterministic. A cancelled ctx ends the wait (Cancel/Close paths). All
|
||||
// fields are read only by the test; started order == start order.
|
||||
type controlledSpeaker struct {
|
||||
started chan Item
|
||||
step chan struct{}
|
||||
consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
|
||||
consumed int // starts consumed by waitUntil (test goroutine only)
|
||||
}
|
||||
|
||||
func neueSpielerin() *gesteuerteSpielerin {
|
||||
return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
|
||||
func newSpeaker() *controlledSpeaker {
|
||||
return &controlledSpeaker{started: make(chan Item, 16), step: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
|
||||
func (g *controlledSpeaker) speak(ctx context.Context, item Item) error {
|
||||
g.started <- item
|
||||
select {
|
||||
case <-g.step:
|
||||
@@ -37,9 +36,9 @@ func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
|
||||
}
|
||||
}
|
||||
|
||||
// warteBis wartet, bis insgesamt n Ansagen gestartet sind, und liefert die
|
||||
// seit dem letzten warteBis-Aufruf neu gestarteten.
|
||||
func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
|
||||
// waitUntil waits until n announcements have started in total and returns
|
||||
// the ones started since the previous waitUntil call.
|
||||
func (g *controlledSpeaker) waitUntil(t *testing.T, n int) []Item {
|
||||
t.Helper()
|
||||
var items []Item
|
||||
deadline := time.After(2 * time.Second)
|
||||
@@ -49,29 +48,29 @@ func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
|
||||
g.consumed++
|
||||
items = append(items, it)
|
||||
case <-deadline:
|
||||
t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
|
||||
t.Fatalf("only %d of %d announcements started", g.consumed, n)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
|
||||
func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
|
||||
// noFurther checks that no further announcement starts (poll window).
|
||||
func (g *controlledSpeaker) noFurther(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case it := <-g.started:
|
||||
g.consumed++
|
||||
t.Fatalf("unerwartete Ansage %q gestartet", it.Text)
|
||||
t.Fatalf("unexpected announcement %q started", it.Text)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
|
||||
// Level. Der erste Blocker besetzt den Worker; danach werden alle Level
|
||||
// gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
|
||||
// (Ohne Gesetze: Null-Laws = unbegrenzt, keine Ruhezeit.)
|
||||
func TestAbarbeitungsReihenfolge(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestProcessingOrder: now before high before normal before low, FIFO per
|
||||
// level. The first blocker occupies the worker; then all levels are
|
||||
// enqueued at once and processing must follow the class.
|
||||
// (No laws: zero Laws = unlimited, no quiet hours.)
|
||||
func TestProcessingOrder(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
@@ -80,131 +79,130 @@ func TestAbarbeitungsReihenfolge(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("Enqueue = %+v, want Aufnahme mit ID", adm)
|
||||
t.Fatalf("Enqueue = %+v, want admission with ID", adm)
|
||||
}
|
||||
g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
|
||||
g.waitUntil(t, 1) // blocker is playing, worker is busy
|
||||
|
||||
for _, it := range []Item{
|
||||
{Text: "niedrig", Priority: PriorityLow},
|
||||
{Text: "zweit-niedrig", Priority: PriorityLow},
|
||||
{Text: "mittel", Priority: PriorityNormal},
|
||||
{Text: "dringend", Priority: PriorityHigh},
|
||||
{Text: "sofort", Now: true},
|
||||
{Text: "low", Priority: PriorityLow},
|
||||
{Text: "second-low", Priority: PriorityLow},
|
||||
{Text: "normal", Priority: PriorityNormal},
|
||||
{Text: "urgent", Priority: PriorityHigh},
|
||||
{Text: "right-now", Now: true},
|
||||
} {
|
||||
if _, err := q.Enqueue(it); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
|
||||
g.noFurther(t) // serialization: the worker is still on the blocker
|
||||
|
||||
// Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
|
||||
// Release: everything else runs through in class order.
|
||||
close(g.step)
|
||||
rest := g.warteBis(t, 6)
|
||||
rest := g.waitUntil(t, 6)
|
||||
|
||||
var texts []string
|
||||
for _, it := range rest {
|
||||
texts = append(texts, it.Text)
|
||||
}
|
||||
want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
|
||||
want := []string{"right-now", "urgent", "normal", "low", "second-low"}
|
||||
if !slices.Equal(texts, want) {
|
||||
t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
|
||||
t.Errorf("processing after the blocker = %v, want %v", texts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
|
||||
func TestCurrentUndPending(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestCurrentAndPending: status view of the playing item and the queue.
|
||||
func TestCurrentAndPending(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
if _, ok := q.Current(); ok {
|
||||
t.Fatal("Current vor erster Ansage: want idle")
|
||||
t.Fatal("Current before the first announcement: want idle")
|
||||
}
|
||||
|
||||
q.Enqueue(Item{Text: "blocker"})
|
||||
g.warteBis(t, 1)
|
||||
g.waitUntil(t, 1)
|
||||
|
||||
q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
|
||||
q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
|
||||
q.Enqueue(Item{Text: "high", Priority: PriorityHigh})
|
||||
q.Enqueue(Item{Text: "low", Priority: PriorityLow})
|
||||
|
||||
if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
|
||||
t.Errorf("Current = %+v (ok=%v), will Blocker", cur, ok)
|
||||
t.Errorf("Current = %+v (ok=%v), want the blocker", cur, ok)
|
||||
}
|
||||
var pendingTexts []string
|
||||
for _, it := range q.Pending() {
|
||||
pendingTexts = append(pendingTexts, it.Text)
|
||||
}
|
||||
if !slices.Equal(pendingTexts, []string{"hoch", "niedrig"}) {
|
||||
t.Errorf("Pending = %v, want [hoch niedrig]", pendingTexts)
|
||||
if !slices.Equal(pendingTexts, []string{"high", "low"}) {
|
||||
t.Errorf("Pending = %v, want [high low]", pendingTexts)
|
||||
}
|
||||
|
||||
close(g.step)
|
||||
g.warteBis(t, 3)
|
||||
g.waitUntil(t, 3)
|
||||
}
|
||||
|
||||
// TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
|
||||
// Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
|
||||
// spielbereit.
|
||||
// TestCancel: the playing announcement is aborted via ctx, the queue
|
||||
// emptied, the total reported; afterwards the queue admits and plays again.
|
||||
func TestCancel(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "blocker"})
|
||||
g.warteBis(t, 1)
|
||||
g.waitUntil(t, 1)
|
||||
|
||||
q.Enqueue(Item{Text: "wartet-eins"})
|
||||
q.Enqueue(Item{Text: "wartet-zwei"})
|
||||
q.Enqueue(Item{Text: "waiting-one"})
|
||||
q.Enqueue(Item{Text: "waiting-two"})
|
||||
|
||||
if n := q.Cancel(); n != 3 {
|
||||
t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
|
||||
t.Errorf("Cancel = %d, want 3 (playing + 2 waiting)", n)
|
||||
}
|
||||
|
||||
g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
|
||||
g.noFurther(t) // blocker ended via ctx, waiting items are gone
|
||||
waitIdle(t, q)
|
||||
|
||||
if n := q.Cancel(); n != 0 {
|
||||
t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
|
||||
t.Errorf("Cancel on empty queue = %d, want 0", n)
|
||||
}
|
||||
|
||||
// Queue nimmt wieder an und spielt:
|
||||
adm, err := q.Enqueue(Item{Text: "danach"})
|
||||
// The queue admits and plays again:
|
||||
adm, err := q.Enqueue(Item{Text: "afterwards"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
|
||||
gestartet := g.warteBis(t, 2)
|
||||
if len(gestartet) != 1 || gestartet[0].ID != adm.ID {
|
||||
t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
|
||||
g.step <- struct{}{} // releases the waiting announcement in the speaker
|
||||
started := g.waitUntil(t, 2)
|
||||
if len(started) != 1 || started[0].ID != adm.ID {
|
||||
t.Errorf("new announcement not started: %+v", started)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClose: wartende Items werden verworfen, die laufende Ansage über den
|
||||
// abgebrochenen ctx beendet, Close wartet auf den Worker-Exit, danach ist
|
||||
// Enqueue ein Fehler (und Close idempotent).
|
||||
// TestClose: waiting items are dropped, the playing announcement ends via
|
||||
// the cancelled ctx, Close waits for the worker to exit, afterwards Enqueue
|
||||
// is an error (and Close idempotent).
|
||||
func TestClose(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
|
||||
q.Enqueue(Item{Text: "blocker"})
|
||||
g.warteBis(t, 1)
|
||||
q.Enqueue(Item{Text: "wartet"})
|
||||
g.waitUntil(t, 1)
|
||||
q.Enqueue(Item{Text: "waiting"})
|
||||
|
||||
q.Close()
|
||||
|
||||
g.keineWeitere(t)
|
||||
g.noFurther(t)
|
||||
if _, ok := q.Current(); ok {
|
||||
t.Error("Current nach Close: want idle")
|
||||
t.Error("Current after Close: want idle")
|
||||
}
|
||||
if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
|
||||
t.Error("Enqueue nach Close: want Fehler")
|
||||
if _, err := q.Enqueue(Item{Text: "too late"}); err == nil {
|
||||
t.Error("Enqueue after Close: want error")
|
||||
}
|
||||
q.Close() // idempotent
|
||||
}
|
||||
|
||||
// TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
|
||||
func TestEnqueueVergibtIDs(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestEnqueueAssignsIDs: IDs are short and unique.
|
||||
func TestEnqueueAssignsIDs(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
@@ -215,15 +213,15 @@ func TestEnqueueVergibtIDs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adm.ID) < 4 || seen[adm.ID] {
|
||||
t.Errorf("ID %q zu kurz oder doppelt", adm.ID)
|
||||
t.Errorf("ID %q too short or duplicate", adm.ID)
|
||||
}
|
||||
seen[adm.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// waitIdle pollt, bis kein Item mehr läuft (Cancel-/Close-Pfade geben das
|
||||
// nach dem Worker-Exit synchron frei; das Pollen deckt die Übergangsphase
|
||||
// ab, ohne auf Timing zu trauen).
|
||||
// waitIdle polls until no item is playing (Cancel/Close release that
|
||||
// synchronously after the worker exits; polling covers the transition
|
||||
// without trusting timing).
|
||||
func waitIdle(t *testing.T, q *Queue) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
@@ -232,314 +230,323 @@ func waitIdle(t *testing.T, q *Queue) {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("Queue wird nicht idle")
|
||||
t.Fatal("queue does not become idle")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares Set.
|
||||
// TestMuteStore: file round trip, missing means not muted, atomic Set.
|
||||
func TestMuteStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, MuteFileName)
|
||||
|
||||
var s MuteStore
|
||||
if s.Muted() {
|
||||
t.Error("MuteStore ohne Pfad: Muted() = true, want false")
|
||||
t.Error("MuteStore without path: Muted() = true, want false")
|
||||
}
|
||||
if err := s.Set(true); err == nil {
|
||||
t.Error("Set ohne Pfad: want Fehler")
|
||||
t.Error("Set without path: want error")
|
||||
}
|
||||
|
||||
s = MuteStore{Path: path}
|
||||
if s.Muted() {
|
||||
t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
|
||||
t.Error("without file: Muted() = true, want false (never silent by accident)")
|
||||
}
|
||||
|
||||
if err := s.Set(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Muted() {
|
||||
t.Error("nach Set(true): Muted() = false")
|
||||
t.Error("after Set(true): Muted() = false")
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != "true\n" {
|
||||
t.Errorf("Datei-Inhalt = %q, want %q", b, "true\n")
|
||||
t.Errorf("file content = %q, want %q", b, "true\n")
|
||||
}
|
||||
|
||||
if err := s.Set(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Muted() {
|
||||
t.Error("nach Set(false): Muted() = true")
|
||||
t.Error("after Set(false): Muted() = true")
|
||||
}
|
||||
|
||||
// Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
|
||||
if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
|
||||
// Garbage in the file means not muted (only "true" is muted).
|
||||
if err := os.WriteFile(path, []byte("maybe\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Muted() {
|
||||
t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
|
||||
t.Error("file content \"maybe\": Muted() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiter: Struktur-Semantik (seit Task 05 in den Loop verwired).
|
||||
// TestRateLimiter: the limiter's own semantics.
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
// Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
|
||||
// Unlimited: interval 0 always allows.
|
||||
unlimited := &RateLimiter{}
|
||||
for i := 0; i < 3; i++ {
|
||||
if !unlimited.Allow() {
|
||||
t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
|
||||
t.Fatal("interval 0: Allow = false, want true (unlimited)")
|
||||
}
|
||||
unlimited.Reserve()
|
||||
}
|
||||
|
||||
// Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
|
||||
// Limited: after Reserve, Allow only after the interval.
|
||||
l := &RateLimiter{Interval: 30 * time.Millisecond}
|
||||
if !l.Allow() {
|
||||
t.Fatal("frisches Limit: Allow = false")
|
||||
t.Fatal("fresh limiter: Allow = false")
|
||||
}
|
||||
l.Reserve()
|
||||
if l.Allow() {
|
||||
t.Fatal("sofort nach Reserve: Allow = true, want false")
|
||||
t.Fatal("right after Reserve: Allow = true, want false")
|
||||
}
|
||||
if next := l.Next(); time.Now().After(next) {
|
||||
t.Errorf("Next() = %v liegt in der Vergangenheit", next)
|
||||
t.Errorf("Next() = %v lies in the past", next)
|
||||
}
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
if !l.Allow() {
|
||||
t.Fatal("nach Interval: Allow = false, want true")
|
||||
t.Fatal("after the interval: Allow = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
|
||||
// (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
|
||||
func fensterUm(t time.Time) string {
|
||||
return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
|
||||
// windowAround builds an "HH:MM-HH:MM" quiet window that safely contains t
|
||||
// (three minutes before to four minutes after; minute resolution).
|
||||
func windowAround(t time.Time) string {
|
||||
return t.Add(-3*time.Minute).Format("15:04") + "-" + t.Add(4*time.Minute).Format("15:04")
|
||||
}
|
||||
|
||||
// warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
|
||||
// gesteuerte Speaker allein sieht sie nicht).
|
||||
func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
|
||||
// waitForCond polls a condition (time laws are real; the controlled speaker
|
||||
// alone cannot see them).
|
||||
func waitForCond(t *testing.T, timeout time.Duration, cond func() bool, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
|
||||
t.Fatalf("%s did not happen within %s", what, timeout)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
|
||||
// erste Ansage sofort; low über dem Budget wird schon beim Eintritt
|
||||
// verworfen (Admission nennt den Grund), normal parkt und spielt nach
|
||||
// Fenster-Ablauf.
|
||||
func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestRateLimitLowDiscardedNormalParks: with a short window the first
|
||||
// announcement plays at once; low over budget is discarded on admission
|
||||
// (Admission names the reason), normal parks and plays after the window.
|
||||
func TestRateLimitLowDiscardedNormalParks(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
|
||||
if adm, err := q.Enqueue(Item{Text: "first"}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("first Enqueue = %+v (%v), want admission", adm, err)
|
||||
}
|
||||
g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
|
||||
g.waitUntil(t, 1) // first is playing, the window is booked
|
||||
|
||||
adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
|
||||
adm, err := q.Enqueue(Item{Text: "low-one", Priority: PriorityLow})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
|
||||
t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
|
||||
t.Errorf("low over budget = %+v, want discard %q", adm, DiscardRateLimit)
|
||||
}
|
||||
if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
|
||||
t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
|
||||
if adm, err = q.Enqueue(Item{Text: "low-two", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
|
||||
t.Errorf("second low discard = %+v (%v)", adm, err)
|
||||
}
|
||||
|
||||
adm, err = q.Enqueue(Item{Text: "zweite"})
|
||||
adm, err = q.Enqueue(Item{Text: "second"})
|
||||
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
|
||||
t.Fatalf("normal = %+v (%v), want admission (parks until the slot)", adm, err)
|
||||
}
|
||||
|
||||
// Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
|
||||
warteAuf(t, time.Second, func() bool {
|
||||
// Status view while the first plays: second waits (normal, parked).
|
||||
waitForCond(t, time.Second, func() bool {
|
||||
for _, it := range q.Pending() {
|
||||
if it.Text == "zweite" {
|
||||
if it.Text == "second" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "geparkte normale Ansage in Pending")
|
||||
}, "parked normal announcement in Pending")
|
||||
|
||||
close(g.step) // erste beenden — zweite muss weiter parken …
|
||||
g.keineWeitere(t)
|
||||
// … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
|
||||
gestartet := g.warteBis(t, 2)
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
|
||||
t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
|
||||
close(g.step) // end the first; second must keep parking …
|
||||
g.noFurther(t)
|
||||
// … and start after the window elapses (deadline covers jitter easily).
|
||||
started := g.waitUntil(t, 2)
|
||||
if len(started) != 1 || started[0].Text != "second" {
|
||||
t.Errorf("after the window = %+v, want second", started)
|
||||
}
|
||||
// low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
|
||||
g.keineWeitere(t)
|
||||
// low-one/low-two never play: exactly 2 starts in total.
|
||||
g.noFurther(t)
|
||||
}
|
||||
|
||||
// TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
|
||||
// laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
|
||||
// wie normal (kein Verstoß).
|
||||
func TestHighBurstEinUndAus(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
// TestHighBurstOnAndOff: with burst rights high starts right after the
|
||||
// playing announcement although the window is full; without rights high
|
||||
// parks like normal (no violation).
|
||||
func TestHighBurstOnAndOff(t *testing.T) {
|
||||
g := newSpeaker()
|
||||
q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
|
||||
|
||||
q.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1)
|
||||
if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
|
||||
q.Enqueue(Item{Text: "first"})
|
||||
g.waitUntil(t, 1)
|
||||
if adm, err := q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high with burst rights = %+v (%v), want admission", adm, err)
|
||||
}
|
||||
g.step <- struct{}{} // erste beenden
|
||||
gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
|
||||
t.Errorf("burst = %+v, will dringend sofort", gestartet)
|
||||
g.step <- struct{}{} // end the first
|
||||
started := g.waitUntil(t, 2) // high starts despite the full window
|
||||
if len(started) != 1 || started[0].Text != "urgent" {
|
||||
t.Errorf("burst = %+v, want urgent right away", started)
|
||||
}
|
||||
q.Close()
|
||||
|
||||
g2 := neueSpielerin()
|
||||
g2 := newSpeaker()
|
||||
q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
|
||||
defer q2.Close()
|
||||
|
||||
q2.Enqueue(Item{Text: "erste"})
|
||||
g2.warteBis(t, 1) // erste läuft — Fenster gebucht
|
||||
if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
|
||||
q2.Enqueue(Item{Text: "first"})
|
||||
g2.waitUntil(t, 1) // first is playing, window booked
|
||||
if adm, err := q2.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high without burst rights = %+v (%v), want admission (parks)", adm, err)
|
||||
}
|
||||
g2.step <- struct{}{} // erste beenden
|
||||
g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
|
||||
g2.step <- struct{}{} // end the first
|
||||
g2.noFurther(t) // high waits for the slot: no violation without rights
|
||||
}
|
||||
|
||||
// TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
|
||||
// der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
|
||||
// TestNowBypassesRateLimit: now starts at once despite a full window, the
|
||||
// documented escape (it still books a new window).
|
||||
func TestNowBypassesRateLimit(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1) // Fenster gebucht (10 s)
|
||||
q.Enqueue(Item{Text: "first"})
|
||||
g.waitUntil(t, 1) // window booked (10 s)
|
||||
|
||||
if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
|
||||
if adm, err := q.Enqueue(Item{Text: "right-now", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("now over a full window = %+v (%v), want admission", adm, err)
|
||||
}
|
||||
g.step <- struct{}{}
|
||||
gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
||||
t.Errorf("now = %+v, will sofort", gestartet)
|
||||
started := g.waitUntil(t, 2) // long before the window elapses (10 s), else timeout
|
||||
if len(started) != 1 || started[0].Text != "right-now" {
|
||||
t.Errorf("now = %+v, want right-now", started)
|
||||
}
|
||||
|
||||
// Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
|
||||
if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("normal nach now = %+v (%v)", adm, err)
|
||||
// The now break-out booked a new window: normal parks now.
|
||||
if adm, err := q.Enqueue(Item{Text: "afterwards"}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("normal after now = %+v (%v)", adm, err)
|
||||
}
|
||||
close(g.step)
|
||||
g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
|
||||
g.noFurther(t) // parks until the slot, not immediately
|
||||
}
|
||||
|
||||
// TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
|
||||
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
|
||||
// now bricht bewusst aus.
|
||||
func TestRuhezeitDiscardUndToast(t *testing.T) {
|
||||
fenster := fensterUm(time.Now())
|
||||
// TestQuietHoursDiscardAndToast: an active window discards normal and high
|
||||
// (burst is a rate right, not a quiet right); toast replaces instead; now
|
||||
// deliberately breaks out.
|
||||
func TestQuietHoursDiscardAndToast(t *testing.T) {
|
||||
window := windowAround(time.Now())
|
||||
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
|
||||
g := newSpeaker()
|
||||
q := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeDiscard}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
adm, err := q.Enqueue(Item{Text: "normal"})
|
||||
if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
|
||||
t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
|
||||
t.Errorf("normal in quiet hours = %+v (%v), want silent discard", adm, err)
|
||||
}
|
||||
if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
|
||||
t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
|
||||
if adm, err = q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
|
||||
t.Errorf("high in quiet hours = %+v (%v), want discard too", adm, err)
|
||||
}
|
||||
adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
|
||||
adm, err = q.Enqueue(Item{Text: "right-now", Now: true})
|
||||
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
|
||||
t.Fatalf("now in quiet hours = %+v (%v), want break-out", adm, err)
|
||||
}
|
||||
gestartet := g.warteBis(t, 1)
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
||||
t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
|
||||
started := g.waitUntil(t, 1)
|
||||
if len(started) != 1 || started[0].Text != "right-now" {
|
||||
t.Errorf("in quiet hours = %+v, want only right-now", started)
|
||||
}
|
||||
g.keineWeitere(t)
|
||||
g.noFurther(t)
|
||||
|
||||
g2 := neueSpielerin()
|
||||
q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
|
||||
g2 := newSpeaker()
|
||||
q2 := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeToast}, g2.speak, nil)
|
||||
defer q2.Close()
|
||||
|
||||
toasted := make(chan Item, 1)
|
||||
q2.OnToast(func(it Item) { toasted <- it })
|
||||
adm, err = q2.Enqueue(Item{Text: "normal"})
|
||||
if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
|
||||
t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
|
||||
t.Errorf("normal in quiet hours (toast) = %+v (%v), want toast replacement", adm, err)
|
||||
}
|
||||
g2.keineWeitere(t)
|
||||
select {
|
||||
case it := <-toasted:
|
||||
if it.Text != "normal" {
|
||||
t.Errorf("toast hook got %q", it.Text)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("toast hook not fired")
|
||||
}
|
||||
g2.noFurther(t)
|
||||
}
|
||||
|
||||
// mustQuiet parst ein Ruhefenster oder bricht den Test ab.
|
||||
func mustQuiet(t *testing.T, fenster string) QuietHours {
|
||||
// mustQuiet parses a quiet window or fails the test.
|
||||
func mustQuiet(t *testing.T, window string) QuietHours {
|
||||
t.Helper()
|
||||
q, err := ParseQuietHours(fenster)
|
||||
q, err := ParseQuietHours(window)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
|
||||
// liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
|
||||
// TestLawState: unlimited yields an open slot; after a booked window the
|
||||
// next slot lies in the future; the quiet window and limits are visible.
|
||||
func TestLawState(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
st := q.LawState()
|
||||
if !st.NextSlot.IsZero() || st.RateLimit != 0 {
|
||||
t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
|
||||
t.Errorf("unlimited = %+v, want zero-time slot", st)
|
||||
}
|
||||
q.Close()
|
||||
|
||||
// Das Ruhezeit-Fenster muss JETZT inaktiv sein, sonst würde die Ansage
|
||||
// unten getoastet statt gesprochen — ein festes Fenster wäre eine
|
||||
// Zeitbombe (rot zu bestimmten Tageszeiten). Deshalb dynamisch: eine
|
||||
// Stunde, die zwei Stunden in der Zukunft beginnt.
|
||||
// The quiet window must be inactive NOW, otherwise the announcement
|
||||
// below would be toasted instead of spoken. A fixed window would be a
|
||||
// time bomb (red at certain hours), hence dynamic: one hour starting two
|
||||
// hours from now.
|
||||
window := inactiveQuietWindow(time.Now())
|
||||
fenster := mustQuiet(t, window)
|
||||
if fenster.Active(time.Now()) {
|
||||
t.Fatalf("Testfenster %s ist aktiv — darf nicht sein", window)
|
||||
quiet := mustQuiet(t, window)
|
||||
if quiet.Active(time.Now()) {
|
||||
t.Fatalf("test window %s is active, must not be", window)
|
||||
}
|
||||
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
|
||||
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: quiet, QuietMode: QuietModeToast, MaxText: 400, MaxAge: 2 * time.Minute}, g.speak, nil)
|
||||
defer q2.Close()
|
||||
st2 := q2.LawState()
|
||||
if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
|
||||
t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
|
||||
if st2.RateLimit != time.Hour || !st2.AllowHighBurst || st2.MaxText != 400 || st2.MaxAge != 2*time.Minute {
|
||||
t.Errorf("LawState = %+v, want 1h window with burst rights and limits 400/2m", st2)
|
||||
}
|
||||
if st2.Quiet.String() != window || st2.QuietMode != QuietModeToast {
|
||||
t.Errorf("Ruhezeit in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
|
||||
t.Errorf("quiet hours in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
|
||||
}
|
||||
if st2.QuietActive || !st2.QuietUntil.IsZero() {
|
||||
t.Errorf("Ruhezeit inaktiv erwartet, LawState meldet active=%v until=%v", st2.QuietActive, st2.QuietUntil)
|
||||
t.Errorf("quiet hours expected inactive, LawState reports active=%v until=%v", st2.QuietActive, st2.QuietUntil)
|
||||
}
|
||||
|
||||
q2.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1)
|
||||
q2.Enqueue(Item{Text: "first"})
|
||||
g.waitUntil(t, 1)
|
||||
if next := q2.LawState().NextSlot; !next.After(time.Now()) {
|
||||
t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
|
||||
t.Errorf("after start = %v, want slot in the future", next)
|
||||
}
|
||||
g.step <- struct{}{}
|
||||
}
|
||||
|
||||
// inactiveQuietWindow liefert ein "HH:MM-HH:MM"-Fenster von einer Stunde,
|
||||
// das zwei Stunden nach now beginnt — es enthält now garantiert nicht, egal
|
||||
// zu welcher Tageszeit der Test läuft (auch über Mitternacht hinweg).
|
||||
// inactiveQuietWindow returns a one-hour "HH:MM-HH:MM" window starting two
|
||||
// hours after now; it never contains now, whatever the time of day (also
|
||||
// across midnight).
|
||||
func inactiveQuietWindow(now time.Time) string {
|
||||
start := now.Add(2 * time.Hour)
|
||||
end := now.Add(3 * time.Hour)
|
||||
return start.Format("15:04") + "-" + end.Format("15:04")
|
||||
}
|
||||
|
||||
// TestInactiveQuietWindow: Das Test-Hilfsfenster darf zu KEINER Tageszeit
|
||||
// aktiv sein — sonst wäre TestLawState wieder eine Zeitbombe.
|
||||
// TestInactiveQuietWindow: the helper window must not be active at ANY time
|
||||
// of day, otherwise TestLawState would be a time bomb again.
|
||||
func TestInactiveQuietWindow(t *testing.T) {
|
||||
base := time.Date(2026, 10, 24, 0, 0, 0, 0, time.Local)
|
||||
for m := 0; m < 24*60; m += 7 {
|
||||
@@ -550,12 +557,12 @@ func TestInactiveQuietWindow(t *testing.T) {
|
||||
t.Fatalf("%s: %v", now.Format("15:04"), err)
|
||||
}
|
||||
if q.Active(now) {
|
||||
t.Errorf("um %s ist Fenster %s aktiv", now.Format("15:04"), w)
|
||||
t.Errorf("at %s the window %s is active", now.Format("15:04"), w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncLog ist ein nebenläufigkeitssicherer Log-Puffer für Worker-Zeilen.
|
||||
// syncLog is a concurrency-safe log buffer for worker lines.
|
||||
type syncLog struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
@@ -573,37 +580,36 @@ func (l *syncLog) String() string {
|
||||
return l.b.String()
|
||||
}
|
||||
|
||||
// TestProtokollFertigNichtAbgebrochen: Eine normal beendete Ansage wird als
|
||||
// „fertig" protokolliert, nicht als „abgebrochen" — der Worker darf seinen
|
||||
// eigenen cancel() nicht als Abbruch deuten. Ein echter Cancel heißt
|
||||
// dagegen „abgebrochen".
|
||||
func TestProtokollFertigNichtAbgebrochen(t *testing.T) {
|
||||
// TestLogFinishedNotCancelled: a normally finished announcement is logged as
|
||||
// "finished", not "cancelled"; the worker must not read its own cancel() as
|
||||
// a cancellation. A real Cancel does log "cancelled".
|
||||
func TestLogFinishedNotCancelled(t *testing.T) {
|
||||
logb := &syncLog{}
|
||||
g := neueSpielerin()
|
||||
g := newSpeaker()
|
||||
q := New(Laws{}, g.speak, logb)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "normal zu ende"})
|
||||
g.warteBis(t, 1)
|
||||
q.Enqueue(Item{Text: "runs to the end"})
|
||||
g.waitUntil(t, 1)
|
||||
g.step <- struct{}{}
|
||||
waitFor(t, func() bool { return strings.Contains(logb.String(), "fertig nach") })
|
||||
if strings.Contains(logb.String(), "abgebrochen") {
|
||||
t.Errorf("fertige Ansage als abgebrochen protokolliert:\n%s", logb.String())
|
||||
waitFor(t, func() bool { return strings.Contains(logb.String(), "finished after") })
|
||||
if strings.Contains(logb.String(), "cancelled") {
|
||||
t.Errorf("finished announcement logged as cancelled:\n%s", logb.String())
|
||||
}
|
||||
|
||||
q.Enqueue(Item{Text: "wird abgebrochen"})
|
||||
g.warteBis(t, 2)
|
||||
q.Enqueue(Item{Text: "gets cancelled"})
|
||||
g.waitUntil(t, 2)
|
||||
q.Cancel()
|
||||
waitFor(t, func() bool { return strings.Contains(logb.String(), "abgebrochen") })
|
||||
waitFor(t, func() bool { return strings.Contains(logb.String(), "cancelled") })
|
||||
}
|
||||
|
||||
// waitFor pollt cond bis zu zwei Sekunden.
|
||||
// waitFor polls cond for up to two seconds.
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("Bedingung nicht binnen 2 s erfüllt")
|
||||
t.Fatal("condition not met within 2 s")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
+24
-24
@@ -7,29 +7,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
|
||||
// Allowed values for quiet_mode (public config value).
|
||||
const (
|
||||
QuietModeDiscard = "discard"
|
||||
QuietModeToast = "toast"
|
||||
)
|
||||
|
||||
// clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
|
||||
// clockTime is a time of day at minute resolution (local time, 24 h).
|
||||
type clockTime struct{ hour, minute int }
|
||||
|
||||
// minutes liefert Minuten seit Mitternacht.
|
||||
// minutes returns minutes since midnight.
|
||||
func (c clockTime) minutes() int { return c.hour*60 + c.minute }
|
||||
|
||||
// String rendert die Uhrzeit als "HH:MM".
|
||||
// String renders the time as "HH:MM".
|
||||
func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
|
||||
|
||||
// QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
|
||||
// (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
|
||||
// rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
|
||||
// QuietHours is a quiet window in local time, for example "18:00-08:00"
|
||||
// (spanning midnight). The zero value (Start == End) is disabled; silence
|
||||
// around the clock is mute, not quiet hours.
|
||||
type QuietHours struct {
|
||||
Start, End clockTime
|
||||
}
|
||||
|
||||
// String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
|
||||
// String renders the window as "HH:MM-HH:MM" (zero value: "").
|
||||
func (q QuietHours) String() string {
|
||||
if q.Start == q.End {
|
||||
return ""
|
||||
@@ -37,17 +37,17 @@ func (q QuietHours) String() string {
|
||||
return q.Start.String() + "-" + q.End.String()
|
||||
}
|
||||
|
||||
// ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
|
||||
// 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
|
||||
// (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
|
||||
// Leerstring ist das deaktivierte Fenster (kein Fehler).
|
||||
// ParseQuietHours parses a quiet window "HH:MM-HH:MM" (local time, 24 h;
|
||||
// hours with one or two digits). Windows across midnight (Start > End) are
|
||||
// allowed; Start == End disables the window. The empty string is the
|
||||
// disabled window (no error).
|
||||
func ParseQuietHours(s string) (QuietHours, error) {
|
||||
if s == "" {
|
||||
return QuietHours{}, nil
|
||||
}
|
||||
startS, endS, ok := strings.Cut(s, "-")
|
||||
if !ok {
|
||||
return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
|
||||
return QuietHours{}, fmt.Errorf("quiet_hours: %q is not HH:MM-HH:MM", s)
|
||||
}
|
||||
start, err := parseClock(startS)
|
||||
if err != nil {
|
||||
@@ -60,29 +60,29 @@ func ParseQuietHours(s string) (QuietHours, error) {
|
||||
return QuietHours{Start: start, End: end}, nil
|
||||
}
|
||||
|
||||
// parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
|
||||
// parseClock parses "H[H]:MM" into its parts.
|
||||
func parseClock(s string) (clockTime, error) {
|
||||
hourS, minuteS, ok := strings.Cut(s, ":")
|
||||
if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
|
||||
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
||||
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
|
||||
}
|
||||
hour, err := strconv.Atoi(hourS)
|
||||
if err != nil {
|
||||
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
||||
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
|
||||
}
|
||||
minute, err := strconv.Atoi(minuteS)
|
||||
if err != nil {
|
||||
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
||||
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
|
||||
return clockTime{}, fmt.Errorf("%q is not a valid time of day (24 h)", s)
|
||||
}
|
||||
return clockTime{hour: hour, minute: minute}, nil
|
||||
}
|
||||
|
||||
// Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
|
||||
// Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
|
||||
// Nachtfenster (Start > End) überspannen Mitternacht.
|
||||
// Active reports whether the local time t lies inside the window. Day
|
||||
// windows (Start < End) run from Start up to but excluding End; night
|
||||
// windows (Start > End) span midnight.
|
||||
func (q QuietHours) Active(t time.Time) bool {
|
||||
if q.Start == q.End {
|
||||
return false
|
||||
@@ -95,15 +95,15 @@ func (q QuietHours) Active(t time.Time) bool {
|
||||
return m >= s || m < e
|
||||
}
|
||||
|
||||
// Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
|
||||
// wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
|
||||
// Until returns the local end of the currently active window; the zero time
|
||||
// when no window is active. Basis for GET /status.
|
||||
func (q QuietHours) Until(t time.Time) time.Time {
|
||||
if !q.Active(t) {
|
||||
return time.Time{}
|
||||
}
|
||||
end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
|
||||
if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
|
||||
end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
|
||||
end = end.AddDate(0, 0, 1) // night window: the end is tomorrow
|
||||
}
|
||||
return end
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// lokal baut eine lokale Test-Zeit (Stunde/Minute reichen — Ruhefenster
|
||||
// arbeiten auf Minuten-Auflösung).
|
||||
func lokal(hour, minute int) time.Time {
|
||||
// local builds a local test time (hour and minute suffice; quiet windows
|
||||
// work at minute resolution).
|
||||
func local(hour, minute int) time.Time {
|
||||
return time.Date(2026, 10, 18, hour, minute, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// TestParseQuietHours: Format HH:MM-HH:MM (überspannt Mitternacht),
|
||||
// klare Fehler für Müll.
|
||||
// TestParseQuietHours: format HH:MM-HH:MM (may span midnight), clear errors
|
||||
// for garbage.
|
||||
func TestParseQuietHours(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
@@ -23,8 +23,8 @@ func TestParseQuietHours(t *testing.T) {
|
||||
{in: "18:00-08:00", start: clockTime{18, 0}, end: clockTime{8, 0}},
|
||||
{in: "9:30-17:45", start: clockTime{9, 30}, end: clockTime{17, 45}},
|
||||
{in: "22:00-06:00", start: clockTime{22, 0}, end: clockTime{6, 0}},
|
||||
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // deaktiviert, aber gültig
|
||||
{in: ""}, // Leerstring = deaktiviert, kein Fehler
|
||||
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // disabled but valid
|
||||
{in: ""}, // empty = disabled, no error
|
||||
{in: "18:00", bad: true},
|
||||
{in: "18:00-", bad: true},
|
||||
{in: "-08:00", bad: true},
|
||||
@@ -37,12 +37,12 @@ func TestParseQuietHours(t *testing.T) {
|
||||
q, err := ParseQuietHours(tc.in)
|
||||
if tc.bad {
|
||||
if err == nil {
|
||||
t.Errorf("ParseQuietHours(%q) = %+v, want Fehler", tc.in, q)
|
||||
t.Errorf("ParseQuietHours(%q) = %+v, want error", tc.in, q)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseQuietHours(%q): unerwarteter Fehler %v", tc.in, err)
|
||||
t.Errorf("ParseQuietHours(%q): unexpected error %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if q.Start != tc.start || q.End != tc.end {
|
||||
@@ -51,69 +51,70 @@ func TestParseQuietHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietHoursActive: Tagfenster, Nachtfenster über Mitternacht und der
|
||||
// deaktivierte Nullwert über alle Grenzen (Start inklusiv, End exklusiv).
|
||||
// TestQuietHoursActive: day window, night window across midnight and the
|
||||
// disabled zero value across all boundaries (start inclusive, end
|
||||
// exclusive).
|
||||
func TestQuietHoursActive(t *testing.T) {
|
||||
cases := []struct {
|
||||
fenster string
|
||||
window string
|
||||
hour, minute int
|
||||
want bool
|
||||
}{
|
||||
// Tagfenster 09:00-17:00
|
||||
// day window 09:00-17:00
|
||||
{"09:00-17:00", 8, 59, false},
|
||||
{"09:00-17:00", 9, 0, true}, // Start inklusiv
|
||||
{"09:00-17:00", 9, 0, true}, // start inclusive
|
||||
{"09:00-17:00", 12, 30, true},
|
||||
{"09:00-17:00", 16, 59, true},
|
||||
{"09:00-17:00", 17, 0, false}, // End exklusiv
|
||||
{"09:00-17:00", 17, 0, false}, // end exclusive
|
||||
{"09:00-17:00", 23, 0, false},
|
||||
// Nachtfenster 18:00-08:00 (überspannt Mitternacht)
|
||||
// night window 18:00-08:00 (spans midnight)
|
||||
{"18:00-08:00", 17, 59, false},
|
||||
{"18:00-08:00", 18, 0, true},
|
||||
{"18:00-08:00", 23, 59, true},
|
||||
{"18:00-08:00", 0, 0, true}, // über Mitternacht
|
||||
{"18:00-08:00", 0, 0, true}, // past midnight
|
||||
{"18:00-08:00", 7, 59, true},
|
||||
{"18:00-08:00", 8, 0, false}, // End exklusiv
|
||||
{"18:00-08:00", 8, 0, false}, // end exclusive
|
||||
{"18:00-08:00", 12, 0, false},
|
||||
// Zweites Nachtfenster
|
||||
// another night window
|
||||
{"22:00-06:00", 3, 0, true},
|
||||
{"22:00-06:00", 21, 59, false},
|
||||
// Start == End: deaktiviert
|
||||
// start == end: disabled
|
||||
{"08:00-08:00", 8, 0, false},
|
||||
{"08:00-08:00", 20, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
q := mustQuiet(t, tc.fenster)
|
||||
if got := q.Active(lokal(tc.hour, tc.minute)); got != tc.want {
|
||||
t.Errorf("%s um %02d:%02d: Active = %v, want %v", tc.fenster, tc.hour, tc.minute, got, tc.want)
|
||||
q := mustQuiet(t, tc.window)
|
||||
if got := q.Active(local(tc.hour, tc.minute)); got != tc.want {
|
||||
t.Errorf("%s at %02d:%02d: Active = %v, want %v", tc.window, tc.hour, tc.minute, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietHoursUntil: Fenster-Ende (heute bzw. morgen bei Nachtfenstern),
|
||||
// Nullzeit wenn inaktiv.
|
||||
// TestQuietHoursUntil: window end (today, or tomorrow for night windows),
|
||||
// zero time when inactive.
|
||||
func TestQuietHoursUntil(t *testing.T) {
|
||||
// Tagfenster: Ende heute.
|
||||
// Day window: ends today.
|
||||
q := mustQuiet(t, "09:00-17:00")
|
||||
if want := lokal(17, 0); !q.Until(lokal(12, 0)).Equal(want) {
|
||||
t.Errorf("Until(12:00) = %v, want %v (heute 17:00)", q.Until(lokal(12, 0)), want)
|
||||
if want := local(17, 0); !q.Until(local(12, 0)).Equal(want) {
|
||||
t.Errorf("Until(12:00) = %v, want %v (today 17:00)", q.Until(local(12, 0)), want)
|
||||
}
|
||||
if !q.Until(lokal(3, 0)).IsZero() {
|
||||
t.Error("Until außerhalb des Fensters: want Nullzeit")
|
||||
if !q.Until(local(3, 0)).IsZero() {
|
||||
t.Error("Until outside the window: want zero time")
|
||||
}
|
||||
|
||||
// Nachtfenster: ab 18:00 ist das Ende morgen früh.
|
||||
// Night window: from 18:00 the end is tomorrow morning.
|
||||
n := mustQuiet(t, "18:00-08:00")
|
||||
if want := lokal(0, 0).Add(32 * time.Hour); !n.Until(lokal(20, 0)).Equal(want) {
|
||||
t.Errorf("Until(20:00) = %v, want %v (morgen 08:00)", n.Until(lokal(20, 0)), want)
|
||||
if want := local(0, 0).Add(32 * time.Hour); !n.Until(local(20, 0)).Equal(want) {
|
||||
t.Errorf("Until(20:00) = %v, want %v (tomorrow 08:00)", n.Until(local(20, 0)), want)
|
||||
}
|
||||
// Nach Mitternacht (00:30) ist das Ende heute 08:00.
|
||||
if want := lokal(8, 0); !n.Until(lokal(0, 30)).Equal(want) {
|
||||
t.Errorf("Until(00:30) = %v, want %v (heute 08:00)", n.Until(lokal(0, 30)), want)
|
||||
// Past midnight (00:30) the end is today 08:00.
|
||||
if want := local(8, 0); !n.Until(local(0, 30)).Equal(want) {
|
||||
t.Errorf("Until(00:30) = %v, want %v (today 08:00)", n.Until(local(0, 30)), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietHoursStringRoundtrip: String liefert die konfigurierte Form
|
||||
// zurück und parst identisch; der Nullwert rendert leer.
|
||||
// TestQuietHoursStringRoundtrip: String returns the configured form and
|
||||
// parses back identically; the zero value renders empty.
|
||||
func TestQuietHoursStringRoundtrip(t *testing.T) {
|
||||
for _, s := range []string{"18:00-08:00", "09:00-17:00", "22:00-06:00"} {
|
||||
q := mustQuiet(t, s)
|
||||
@@ -122,10 +123,10 @@ func TestQuietHoursStringRoundtrip(t *testing.T) {
|
||||
}
|
||||
q2 := mustQuiet(t, q.String())
|
||||
if q2 != q {
|
||||
t.Errorf("Roundtrip von %q: %+v ≠ %+v", s, q2, q)
|
||||
t.Errorf("round trip of %q: %+v != %+v", s, q2, q)
|
||||
}
|
||||
}
|
||||
if (QuietHours{}).String() != "" {
|
||||
t.Error("Nullwert-String nicht leer")
|
||||
t.Error("zero value String is not empty")
|
||||
}
|
||||
}
|
||||
+79
-82
@@ -7,32 +7,32 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
|
||||
// Discard reasons in the HTTP contract of POST /speak
|
||||
// (202 {"id":"","discarded":"…"}).
|
||||
const (
|
||||
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
|
||||
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
|
||||
DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet)
|
||||
DiscardRateLimit = "rate_limit" // priority=low over the window budget
|
||||
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
|
||||
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
|
||||
)
|
||||
|
||||
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
|
||||
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
|
||||
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
|
||||
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
|
||||
// nicht deren Legitimität.
|
||||
// RateLimiter is the time window of the daemon's anti-spam law: default
|
||||
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
|
||||
// started announcement books the window anew (Reserve), including ones that
|
||||
// broke out via now or high burst: the window counts playback starts, not
|
||||
// their legitimacy.
|
||||
//
|
||||
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
|
||||
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
|
||||
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
|
||||
// queue lock during Allow/Reserve/Next, never the other way round.
|
||||
type RateLimiter struct {
|
||||
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
|
||||
// 0 oder negativ = unbegrenzt.
|
||||
// Interval is the minimum gap between two announcement starts. 0 or
|
||||
// negative = unlimited.
|
||||
Interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
|
||||
last time.Time // start of the announcement last booked via Reserve
|
||||
}
|
||||
|
||||
// Allow meldet, ob sofort eine Ansage starten dürfte.
|
||||
// Allow reports whether an announcement could start right now.
|
||||
func (l *RateLimiter) Allow() bool {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return true
|
||||
@@ -42,10 +42,9 @@ func (l *RateLimiter) Allow() bool {
|
||||
return time.Since(l.last) >= l.Interval
|
||||
}
|
||||
|
||||
// Reserve bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
|
||||
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
|
||||
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
|
||||
// werden).
|
||||
// Reserve books a slot. Call it right after the play decision, before actual
|
||||
// playback: failed announcements consume the window too, so a broken
|
||||
// provider cannot turn into a machine gun.
|
||||
func (l *RateLimiter) Reserve() {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return
|
||||
@@ -55,9 +54,9 @@ func (l *RateLimiter) Reserve() {
|
||||
l.last = time.Now()
|
||||
}
|
||||
|
||||
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
|
||||
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
|
||||
// Basis für das Parken wartender Items und den Status-Endpoint.
|
||||
// Next returns when the next slot opens; the zero time when Allow would
|
||||
// return true right away (unlimited or window elapsed). Basis for parking
|
||||
// waiting items and for the status endpoint.
|
||||
func (l *RateLimiter) Next() time.Time {
|
||||
if l == nil || l.Interval <= 0 {
|
||||
return time.Time{}
|
||||
@@ -70,68 +69,65 @@ func (l *RateLimiter) Next() time.Time {
|
||||
return l.last.Add(l.Interval)
|
||||
}
|
||||
|
||||
// Laws sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
|
||||
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
|
||||
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
|
||||
// Gesetze aus der Config; Default siehe internal/config.Defaults.
|
||||
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
|
||||
// rights for high, quiet hours, text length cap and maximum queue age. The
|
||||
// zero value allows everything; the daemon supplies the laws from the
|
||||
// config (defaults in internal/config.Defaults).
|
||||
type Laws struct {
|
||||
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
|
||||
// (0 = unbegrenzt).
|
||||
// RateLimit is the minimum gap between two announcement starts
|
||||
// (0 = unlimited).
|
||||
RateLimit time.Duration
|
||||
|
||||
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
|
||||
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
|
||||
// Die Ruhezeit gilt davon unberührt auch für high.
|
||||
// AllowHighBurst lets priority=high deliberately break the rate limit
|
||||
// (default true). Without burst rights, high waits like normal. Quiet
|
||||
// hours apply to high regardless.
|
||||
AllowHighBurst bool
|
||||
|
||||
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
|
||||
// Quiet is the quiet window (local time; zero value = disabled).
|
||||
Quiet QuietHours
|
||||
|
||||
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
|
||||
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
|
||||
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
|
||||
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
|
||||
// Einschränkung, siehe Task-05-Report).
|
||||
// QuietMode is the behaviour during quiet hours: "discard" drops
|
||||
// silently, "toast" replaces audio with a desktop notification (the
|
||||
// queue fires the OnToast hook; the daemon wires the notification).
|
||||
QuietMode string
|
||||
|
||||
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (Runen).
|
||||
// Längere Texte werden beim Eintritt an einer Wortgrenze gekürzt und
|
||||
// als gekürzt gemeldet — ein Agent, der die Langantwort vorliest,
|
||||
// kostet so weder Minuten noch API-Budget; der Nutzer hört den Anfang.
|
||||
// 0 = unbegrenzt.
|
||||
// MaxText caps announcement length in characters (runes). Longer texts
|
||||
// are cut at a word boundary on admission and reported as truncated: an
|
||||
// agent reading out its long answer costs neither minutes nor API
|
||||
// budget, and the user still hears the beginning. 0 = unlimited.
|
||||
MaxText int
|
||||
|
||||
// MaxAge ist die längste Wartezeit einer Ansage in der Queue. Was
|
||||
// länger parkt (Rate-Limit, Stau), ist veraltet und wird beim
|
||||
// Fälligwerden verworfen (DiscardExpired) statt gesprochen. 0 =
|
||||
// unbegrenzt. now:true wartet nie lange und ist davon ausgenommen.
|
||||
// MaxAge is the longest an announcement may wait in the queue. Whatever
|
||||
// parks longer (rate limit, backlog) is stale and gets discarded when it
|
||||
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
|
||||
// never waits long and is exempt.
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
|
||||
// verdict is the laws' decision about a due item.
|
||||
type verdict int
|
||||
|
||||
const (
|
||||
verdictPlay verdict = iota // abspielen
|
||||
verdictWait // parken bis zum freien Slot
|
||||
verdictDiscardRate // verwerfen: low über dem Budget
|
||||
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
|
||||
verdictToast // Toast statt Audio (Ruhezeit)
|
||||
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
|
||||
verdictPlay verdict = iota // play
|
||||
verdictWait // park until the slot opens
|
||||
verdictDiscardRate // discard: low over budget
|
||||
verdictDiscardQuiet // discard: quiet hours (discard)
|
||||
verdictToast // toast instead of audio (quiet hours)
|
||||
verdictDiscardExpired // discard: waited longer than MaxAge
|
||||
)
|
||||
|
||||
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
|
||||
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
|
||||
// decide applies the laws to a due item (pure, no state): slotFree says
|
||||
// whether the rate-limit slot is open right now. Precedence:
|
||||
//
|
||||
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
|
||||
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
|
||||
// Rate-Fenster).
|
||||
// 2. Veraltet: Hat das Item länger als MaxAge gewartet, wird es verworfen
|
||||
// — eine Ansage von vor fünf Minuten ist keine Nachricht mehr.
|
||||
// 3. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
|
||||
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
|
||||
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
|
||||
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
|
||||
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
|
||||
// (the documented escape for urgent matters; it still books a new rate
|
||||
// window).
|
||||
// 2. Stale: an item that waited longer than MaxAge is discarded; an
|
||||
// announcement from five minutes ago is no longer news.
|
||||
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
|
||||
// because burst is a rate right, not a quiet right.
|
||||
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
|
||||
// discarded; normal (and high without rights) park until the slot.
|
||||
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
|
||||
if item.Now {
|
||||
return verdictPlay
|
||||
@@ -157,42 +153,43 @@ func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
|
||||
return verdictWait
|
||||
}
|
||||
|
||||
// Admission ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
|
||||
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
|
||||
// Admission is the result of Enqueue: admitted items carry their ID,
|
||||
// discarded ones name the reason (the 202 response of POST /speak carries
|
||||
// it).
|
||||
type Admission struct {
|
||||
// ID der aufgenommenen Ansage (leer, wenn verworfen).
|
||||
// ID of the admitted announcement (empty when discarded).
|
||||
ID string
|
||||
|
||||
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
|
||||
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
|
||||
// Discarded is the discard reason (DiscardRateLimit or
|
||||
// DiscardQuietHours); empty when the announcement was admitted.
|
||||
Discarded string
|
||||
|
||||
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
|
||||
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
|
||||
// Toast reports that the announcement was replaced by a notification
|
||||
// during quiet hours (quiet_mode = "toast").
|
||||
Toast bool
|
||||
|
||||
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
|
||||
// gekürzte Fassung ist aufgenommen).
|
||||
// Truncated reports that the text was cut to MaxText (the shortened
|
||||
// version was admitted).
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
|
||||
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
|
||||
// LawState is the /status view of the laws: the configured rate limit with
|
||||
// the next open slot, the quiet window, and the length and age limits.
|
||||
type LawState struct {
|
||||
RateLimit time.Duration
|
||||
AllowHighBurst bool
|
||||
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
|
||||
NextSlot time.Time // zero = slot open (or unlimited)
|
||||
Quiet QuietHours
|
||||
QuietActive bool
|
||||
QuietMode string
|
||||
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
|
||||
MaxText int // 0 = unbegrenzt
|
||||
MaxAge time.Duration // 0 = unbegrenzt
|
||||
QuietUntil time.Time // zero = quiet hours inactive
|
||||
MaxText int // 0 = unlimited
|
||||
MaxAge time.Duration // 0 = unlimited
|
||||
}
|
||||
|
||||
// truncateText kürzt s auf höchstens max Runen, bevorzugt an einer
|
||||
// Wortgrenze (sofern die nicht in der ersten Hälfte liegt), und meldet, ob
|
||||
// gekürzt wurde. max <= 0 = unbegrenzt.
|
||||
// truncateText cuts s to at most max runes, preferably at a word boundary
|
||||
// (unless that lies in the first half), and reports whether it cut.
|
||||
// max <= 0 = unlimited.
|
||||
func truncateText(s string, max int) (string, bool) {
|
||||
if max <= 0 {
|
||||
return s, false
|
||||
|
||||
Reference in new issue
Block a user