2026-10-18 17:10:26 +02:00
|
|
|
package queue
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"slices"
|
2026-10-24 09:50:08 +02:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-10-18 17:10:26 +02:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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 {
|
2026-10-18 17:10:26 +02:00
|
|
|
started chan Item
|
|
|
|
|
step chan struct{}
|
2026-10-24 11:15:35 +02:00
|
|
|
consumed int // starts consumed by waitUntil (test goroutine only)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
func newSpeaker() *controlledSpeaker {
|
|
|
|
|
return &controlledSpeaker{started: make(chan Item, 16), step: make(chan struct{})}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
func (g *controlledSpeaker) speak(ctx context.Context, item Item) error {
|
2026-10-18 17:10:26 +02:00
|
|
|
g.started <- item
|
|
|
|
|
select {
|
|
|
|
|
case <-g.step:
|
|
|
|
|
return nil
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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 {
|
2026-10-18 17:10:26 +02:00
|
|
|
t.Helper()
|
|
|
|
|
var items []Item
|
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
|
|
|
for g.consumed < n {
|
|
|
|
|
select {
|
|
|
|
|
case it := <-g.started:
|
|
|
|
|
g.consumed++
|
|
|
|
|
items = append(items, it)
|
|
|
|
|
case <-deadline:
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("only %d of %d announcements started", g.consumed, n)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// noFurther checks that no further announcement starts (poll window).
|
|
|
|
|
func (g *controlledSpeaker) noFurther(t *testing.T) {
|
2026-10-18 17:10:26 +02:00
|
|
|
t.Helper()
|
|
|
|
|
select {
|
|
|
|
|
case it := <-g.started:
|
|
|
|
|
g.consumed++
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("unexpected announcement %q started", it.Text)
|
2026-10-18 17:10:26 +02:00
|
|
|
case <-time.After(150 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
2026-10-18 17:10:26 +02:00
|
|
|
defer q.Close()
|
|
|
|
|
|
2026-10-18 17:40:41 +02:00
|
|
|
adm, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
if adm.ID == "" || adm.Discarded != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Enqueue = %+v, want admission with ID", adm)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 1) // blocker is playing, worker is busy
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
for _, it := range []Item{
|
2026-10-24 11:15:35 +02:00
|
|
|
{Text: "low", Priority: PriorityLow},
|
|
|
|
|
{Text: "second-low", Priority: PriorityLow},
|
|
|
|
|
{Text: "normal", Priority: PriorityNormal},
|
|
|
|
|
{Text: "urgent", Priority: PriorityHigh},
|
|
|
|
|
{Text: "right-now", Now: true},
|
2026-10-18 17:10:26 +02:00
|
|
|
} {
|
|
|
|
|
if _, err := q.Enqueue(it); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
g.noFurther(t) // serialization: the worker is still on the blocker
|
2026-10-18 17:10:26 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Release: everything else runs through in class order.
|
2026-10-18 17:10:26 +02:00
|
|
|
close(g.step)
|
2026-10-24 11:15:35 +02:00
|
|
|
rest := g.waitUntil(t, 6)
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
var texts []string
|
|
|
|
|
for _, it := range rest {
|
|
|
|
|
texts = append(texts, it.Text)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
want := []string{"right-now", "urgent", "normal", "low", "second-low"}
|
2026-10-18 17:10:26 +02:00
|
|
|
if !slices.Equal(texts, want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("processing after the blocker = %v, want %v", texts, want)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCurrentAndPending: status view of the playing item and the queue.
|
|
|
|
|
func TestCurrentAndPending(t *testing.T) {
|
|
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
2026-10-18 17:10:26 +02:00
|
|
|
defer q.Close()
|
|
|
|
|
|
|
|
|
|
if _, ok := q.Current(); ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Current before the first announcement: want idle")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 1)
|
2026-10-18 17:10:26 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q.Enqueue(Item{Text: "high", Priority: PriorityHigh})
|
|
|
|
|
q.Enqueue(Item{Text: "low", Priority: PriorityLow})
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Current = %+v (ok=%v), want the blocker", cur, ok)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
var pendingTexts []string
|
|
|
|
|
for _, it := range q.Pending() {
|
|
|
|
|
pendingTexts = append(pendingTexts, it.Text)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !slices.Equal(pendingTexts, []string{"high", "low"}) {
|
|
|
|
|
t.Errorf("Pending = %v, want [high low]", pendingTexts)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(g.step)
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 3)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCancel: the playing announcement is aborted via ctx, the queue
|
|
|
|
|
// emptied, the total reported; afterwards the queue admits and plays again.
|
2026-10-18 17:10:26 +02:00
|
|
|
func TestCancel(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
2026-10-18 17:10:26 +02:00
|
|
|
defer q.Close()
|
|
|
|
|
|
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 1)
|
2026-10-18 17:10:26 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q.Enqueue(Item{Text: "waiting-one"})
|
|
|
|
|
q.Enqueue(Item{Text: "waiting-two"})
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
if n := q.Cancel(); n != 3 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Cancel = %d, want 3 (playing + 2 waiting)", n)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
g.noFurther(t) // blocker ended via ctx, waiting items are gone
|
2026-10-18 17:10:26 +02:00
|
|
|
waitIdle(t, q)
|
|
|
|
|
|
|
|
|
|
if n := q.Cancel(); n != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Cancel on empty queue = %d, want 0", n)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// The queue admits and plays again:
|
|
|
|
|
adm, err := q.Enqueue(Item{Text: "afterwards"})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 17:10:26 +02:00
|
|
|
func TestClose(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 1)
|
|
|
|
|
q.Enqueue(Item{Text: "waiting"})
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
g.noFurther(t)
|
2026-10-18 17:10:26 +02:00
|
|
|
if _, ok := q.Current(); ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Current after Close: want idle")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := q.Enqueue(Item{Text: "too late"}); err == nil {
|
|
|
|
|
t.Error("Enqueue after Close: want error")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
q.Close() // idempotent
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEnqueueAssignsIDs: IDs are short and unique.
|
|
|
|
|
func TestEnqueueAssignsIDs(t *testing.T) {
|
|
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
2026-10-18 17:10:26 +02:00
|
|
|
defer q.Close()
|
|
|
|
|
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
for i := 0; i < 20; i++ {
|
2026-10-18 17:40:41 +02:00
|
|
|
adm, err := q.Enqueue(Item{Text: "x"})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
if len(adm.ID) < 4 || seen[adm.ID] {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ID %q too short or duplicate", adm.ID)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
seen[adm.ID] = true
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// waitIdle polls until no item is playing (Cancel/Close release that
|
|
|
|
|
// synchronously after the worker exits; polling covers the transition
|
|
|
|
|
// without trusting timing).
|
2026-10-18 17:10:26 +02:00
|
|
|
func waitIdle(t *testing.T, q *Queue) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
|
|
|
for {
|
|
|
|
|
if _, ok := q.Current(); !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if time.Now().After(deadline) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("queue does not become idle")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestMuteStore: file round trip, missing means not muted, atomic Set.
|
2026-10-18 17:10:26 +02:00
|
|
|
func TestMuteStore(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, MuteFileName)
|
|
|
|
|
|
|
|
|
|
var s MuteStore
|
|
|
|
|
if s.Muted() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("MuteStore without path: Muted() = true, want false")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if err := s.Set(true); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Set without path: want error")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s = MuteStore{Path: path}
|
|
|
|
|
if s.Muted() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("without file: Muted() = true, want false (never silent by accident)")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.Set(true); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !s.Muted() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("after Set(true): Muted() = false")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if b, _ := os.ReadFile(path); string(b) != "true\n" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("file content = %q, want %q", b, "true\n")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.Set(false); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if s.Muted() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("after Set(false): Muted() = true")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Garbage in the file means not muted (only "true" is muted).
|
|
|
|
|
if err := os.WriteFile(path, []byte("maybe\n"), 0o644); err != nil {
|
2026-10-18 17:10:26 +02:00
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if s.Muted() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("file content \"maybe\": Muted() = true, want false")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestRateLimiter: the limiter's own semantics.
|
2026-10-18 17:10:26 +02:00
|
|
|
func TestRateLimiter(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Unlimited: interval 0 always allows.
|
2026-10-18 17:10:26 +02:00
|
|
|
unlimited := &RateLimiter{}
|
|
|
|
|
for i := 0; i < 3; i++ {
|
|
|
|
|
if !unlimited.Allow() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("interval 0: Allow = false, want true (unlimited)")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
unlimited.Reserve()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Limited: after Reserve, Allow only after the interval.
|
2026-10-18 17:10:26 +02:00
|
|
|
l := &RateLimiter{Interval: 30 * time.Millisecond}
|
|
|
|
|
if !l.Allow() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("fresh limiter: Allow = false")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
l.Reserve()
|
|
|
|
|
if l.Allow() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("right after Reserve: Allow = true, want false")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if next := l.Next(); time.Now().After(next) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Next() = %v lies in the past", next)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
time.Sleep(40 * time.Millisecond)
|
|
|
|
|
if !l.Allow() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("after the interval: Allow = false, want true")
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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")
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:41 +02:00
|
|
|
t.Helper()
|
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
|
for !cond() {
|
|
|
|
|
if time.Now().After(deadline) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("%s did not happen within %s", what, timeout)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
|
|
|
|
|
defer q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
if adm, err := q.Enqueue(Item{Text: "first"}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
|
|
|
|
t.Fatalf("first Enqueue = %+v (%v), want admission", adm, err)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
g.waitUntil(t, 1) // first is playing, the window is booked
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
adm, err := q.Enqueue(Item{Text: "low-one", Priority: PriorityLow})
|
2026-10-18 17:40:41 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("low over budget = %+v, want discard %q", adm, DiscardRateLimit)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
adm, err = q.Enqueue(Item{Text: "second"})
|
2026-10-18 17:40:41 +02:00
|
|
|
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("normal = %+v (%v), want admission (parks until the slot)", adm, err)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Status view while the first plays: second waits (normal, parked).
|
|
|
|
|
waitForCond(t, time.Second, func() bool {
|
2026-10-18 17:40:41 +02:00
|
|
|
for _, it := range q.Pending() {
|
2026-10-24 11:15:35 +02:00
|
|
|
if it.Text == "second" {
|
2026-10-18 17:40:41 +02:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
2026-10-24 11:15:35 +02:00
|
|
|
}, "parked normal announcement in Pending")
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// low-one/low-two never play: exactly 2 starts in total.
|
|
|
|
|
g.noFurther(t)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
g2 := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
|
|
|
|
|
defer q2.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
g2.step <- struct{}{} // end the first
|
|
|
|
|
g2.noFurther(t) // high waits for the slot: no violation without rights
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestNowBypassesRateLimit: now starts at once despite a full window, the
|
|
|
|
|
// documented escape (it still books a new window).
|
2026-10-18 17:40:41 +02:00
|
|
|
func TestNowBypassesRateLimit(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
|
|
|
|
|
defer q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q.Enqueue(Item{Text: "first"})
|
|
|
|
|
g.waitUntil(t, 1) // window booked (10 s)
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
g.step <- struct{}{}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
close(g.step)
|
2026-10-24 11:15:35 +02:00
|
|
|
g.noFurther(t) // parks until the slot, not immediately
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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())
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
|
|
|
|
q := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeDiscard}, g.speak, nil)
|
2026-10-18 17:40:41 +02:00
|
|
|
defer q.Close()
|
|
|
|
|
|
|
|
|
|
adm, err := q.Enqueue(Item{Text: "normal"})
|
|
|
|
|
if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("normal in quiet hours = %+v (%v), want silent discard", adm, err)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
adm, err = q.Enqueue(Item{Text: "right-now", Now: true})
|
2026-10-18 17:40:41 +02:00
|
|
|
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("now in quiet hours = %+v (%v), want break-out", adm, err)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
g.noFurther(t)
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
g2 := newSpeaker()
|
|
|
|
|
q2 := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeToast}, g2.speak, nil)
|
2026-10-18 17:40:41 +02:00
|
|
|
defer q2.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
toasted := make(chan Item, 1)
|
|
|
|
|
q2.OnToast(func(it Item) { toasted <- it })
|
2026-10-18 17:40:41 +02:00
|
|
|
adm, err = q2.Enqueue(Item{Text: "normal"})
|
|
|
|
|
if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("normal in quiet hours (toast) = %+v (%v), want toast replacement", adm, err)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// mustQuiet parses a quiet window or fails the test.
|
|
|
|
|
func mustQuiet(t *testing.T, window string) QuietHours {
|
2026-10-18 17:40:41 +02:00
|
|
|
t.Helper()
|
2026-10-24 11:15:35 +02:00
|
|
|
q, err := ParseQuietHours(window)
|
2026-10-18 17:40:41 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return q
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLawState: unlimited yields an open slot; after a booked window the
|
|
|
|
|
// next slot lies in the future; the quiet window and limits are visible.
|
2026-10-18 17:40:41 +02:00
|
|
|
func TestLawState(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
2026-10-18 17:40:41 +02:00
|
|
|
q := New(Laws{}, g.speak, nil)
|
|
|
|
|
st := q.LawState()
|
|
|
|
|
if !st.NextSlot.IsZero() || st.RateLimit != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("unlimited = %+v, want zero-time slot", st)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:35:45 +02:00
|
|
|
window := inactiveQuietWindow(time.Now())
|
2026-10-24 11:15:35 +02:00
|
|
|
quiet := mustQuiet(t, window)
|
|
|
|
|
if quiet.Active(time.Now()) {
|
|
|
|
|
t.Fatalf("test window %s is active, must not be", window)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: quiet, QuietMode: QuietModeToast, MaxText: 400, MaxAge: 2 * time.Minute}, g.speak, nil)
|
2026-10-18 17:40:41 +02:00
|
|
|
defer q2.Close()
|
|
|
|
|
st2 := q2.LawState()
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
2026-10-24 09:35:45 +02:00
|
|
|
if st2.Quiet.String() != window || st2.QuietMode != QuietModeToast {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("quiet hours in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
if st2.QuietActive || !st2.QuietUntil.IsZero() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("quiet hours expected inactive, LawState reports active=%v until=%v", st2.QuietActive, st2.QuietUntil)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q2.Enqueue(Item{Text: "first"})
|
|
|
|
|
g.waitUntil(t, 1)
|
2026-10-18 17:40:41 +02:00
|
|
|
if next := q2.LawState().NextSlot; !next.After(time.Now()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("after start = %v, want slot in the future", next)
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
g.step <- struct{}{}
|
|
|
|
|
}
|
2026-10-24 09:35:45 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-24 09:35:45 +02:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestInactiveQuietWindow: the helper window must not be active at ANY time
|
|
|
|
|
// of day, otherwise TestLawState would be a time bomb again.
|
2026-10-24 09:35:45 +02:00
|
|
|
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 {
|
|
|
|
|
now := base.Add(time.Duration(m) * time.Minute)
|
|
|
|
|
w := inactiveQuietWindow(now)
|
|
|
|
|
q, err := ParseQuietHours(w)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("%s: %v", now.Format("15:04"), err)
|
|
|
|
|
}
|
|
|
|
|
if q.Active(now) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("at %s the window %s is active", now.Format("15:04"), w)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 09:50:08 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// syncLog is a concurrency-safe log buffer for worker lines.
|
2026-10-24 09:50:08 +02:00
|
|
|
type syncLog struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
b strings.Builder
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *syncLog) Write(p []byte) (int, error) {
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
return l.b.Write(p)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *syncLog) String() string {
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
return l.b.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-24 09:50:08 +02:00
|
|
|
logb := &syncLog{}
|
2026-10-24 11:15:35 +02:00
|
|
|
g := newSpeaker()
|
2026-10-24 09:50:08 +02:00
|
|
|
q := New(Laws{}, g.speak, logb)
|
|
|
|
|
defer q.Close()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q.Enqueue(Item{Text: "runs to the end"})
|
|
|
|
|
g.waitUntil(t, 1)
|
2026-10-24 09:50:08 +02:00
|
|
|
g.step <- struct{}{}
|
2026-10-24 11:15:35 +02:00
|
|
|
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())
|
2026-10-24 09:50:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
q.Enqueue(Item{Text: "gets cancelled"})
|
|
|
|
|
g.waitUntil(t, 2)
|
2026-10-24 09:50:08 +02:00
|
|
|
q.Cancel()
|
2026-10-24 11:15:35 +02:00
|
|
|
waitFor(t, func() bool { return strings.Contains(logb.String(), "cancelled") })
|
2026-10-24 09:50:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// waitFor polls cond for up to two seconds.
|
2026-10-24 09:50:08 +02:00
|
|
|
func waitFor(t *testing.T, cond func() bool) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
for !cond() {
|
|
|
|
|
if time.Now().After(deadline) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("condition not met within 2 s")
|
2026-10-24 09:50:08 +02:00
|
|
|
}
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 12:10:49 +02:00
|
|
|
|
|
|
|
|
// TestCancelItem: cancels only the playing item with that id; a stale id or
|
|
|
|
|
// a waiting item is a no-op, the queue goes on with the next item.
|
|
|
|
|
func TestCancelItem(t *testing.T) {
|
|
|
|
|
g := newSpeaker()
|
|
|
|
|
q := New(Laws{}, g.speak, nil)
|
|
|
|
|
defer q.Close()
|
|
|
|
|
|
|
|
|
|
first, _ := q.Enqueue(Item{Text: "playing"})
|
|
|
|
|
g.waitUntil(t, 1)
|
|
|
|
|
second, _ := q.Enqueue(Item{Text: "waiting"})
|
|
|
|
|
|
|
|
|
|
if q.CancelItem(second.ID) {
|
|
|
|
|
t.Error("CancelItem on a waiting item reported a hit")
|
|
|
|
|
}
|
|
|
|
|
if q.CancelItem("nosuch") {
|
|
|
|
|
t.Error("CancelItem with an unknown id reported a hit")
|
|
|
|
|
}
|
|
|
|
|
if !q.CancelItem(first.ID) {
|
|
|
|
|
t.Fatal("CancelItem on the playing item reported no hit")
|
|
|
|
|
}
|
|
|
|
|
// The playing item ended via ctx; the waiting one starts next.
|
|
|
|
|
started := g.waitUntil(t, 2)
|
|
|
|
|
if len(started) != 1 || started[0].ID != second.ID {
|
|
|
|
|
t.Errorf("after CancelItem started = %+v, want the waiting item", started)
|
|
|
|
|
}
|
|
|
|
|
g.step <- struct{}{}
|
|
|
|
|
}
|