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
+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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user