- Worker-Loop: eine Ansage gleichzeitig; now > high > normal > low, FIFO je Level - Cancel bricht die laufende Ansage ab (ctx) und leert die Queue (Zahl als Rückgabe) - Close verwirft wartende Items, bricht die laufende Ansage ab und wartet auf den Worker - MuteStore: persistenter Mute-State als Datei mute im Config-Dir (atomar geschrieben) - RateLimiter: Struktur + Semantik für Task 05, bewusst noch nicht angebunden (Task-04-Default unbegrenzt, dokumentierte Seam in rate.go)
310 lines
8.1 KiB
Go
310 lines
8.1 KiB
Go
package queue
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
"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 {
|
|
started chan Item
|
|
step chan struct{}
|
|
consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
|
|
}
|
|
|
|
func neueSpielerin() *gesteuerteSpielerin {
|
|
return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
|
|
}
|
|
|
|
func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
|
|
g.started <- item
|
|
select {
|
|
case <-g.step:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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:
|
|
t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
// keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
|
|
func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case it := <-g.started:
|
|
g.consumed++
|
|
t.Fatalf("unerwartete Ansage %q gestartet", 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.
|
|
func TestAbarbeitungsReihenfolge(t *testing.T) {
|
|
g := neueSpielerin()
|
|
q := New(g.speak, nil)
|
|
defer q.Close()
|
|
|
|
erster, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if erster == "" {
|
|
t.Fatal("Enqueue vergibt keine ID")
|
|
}
|
|
g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
|
|
|
|
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},
|
|
} {
|
|
if _, err := q.Enqueue(it); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
|
|
|
|
// Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
|
|
close(g.step)
|
|
rest := g.warteBis(t, 6)
|
|
|
|
var texts []string
|
|
for _, it := range rest {
|
|
texts = append(texts, it.Text)
|
|
}
|
|
want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
|
|
if !slices.Equal(texts, want) {
|
|
t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
|
|
}
|
|
}
|
|
|
|
// TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
|
|
func TestCurrentUndPending(t *testing.T) {
|
|
g := neueSpielerin()
|
|
q := New(g.speak, nil)
|
|
defer q.Close()
|
|
|
|
if _, ok := q.Current(); ok {
|
|
t.Fatal("Current vor erster Ansage: want idle")
|
|
}
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
|
g.warteBis(t, 1)
|
|
|
|
q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
|
|
q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
|
|
|
|
if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
|
|
t.Errorf("Current = %+v (ok=%v), will 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)
|
|
}
|
|
|
|
close(g.step)
|
|
g.warteBis(t, 3)
|
|
}
|
|
|
|
// TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
|
|
// Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
|
|
// spielbereit.
|
|
func TestCancel(t *testing.T) {
|
|
g := neueSpielerin()
|
|
q := New(g.speak, nil)
|
|
defer q.Close()
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
|
g.warteBis(t, 1)
|
|
|
|
q.Enqueue(Item{Text: "wartet-eins"})
|
|
q.Enqueue(Item{Text: "wartet-zwei"})
|
|
|
|
if n := q.Cancel(); n != 3 {
|
|
t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
|
|
}
|
|
|
|
g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
|
|
waitIdle(t, q)
|
|
|
|
if n := q.Cancel(); n != 0 {
|
|
t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
|
|
}
|
|
|
|
// Queue nimmt wieder an und spielt:
|
|
id, err := q.Enqueue(Item{Text: "danach"})
|
|
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 != id {
|
|
t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
func TestClose(t *testing.T) {
|
|
g := neueSpielerin()
|
|
q := New(g.speak, nil)
|
|
|
|
q.Enqueue(Item{Text: "blocker"})
|
|
g.warteBis(t, 1)
|
|
q.Enqueue(Item{Text: "wartet"})
|
|
|
|
q.Close()
|
|
|
|
g.keineWeitere(t)
|
|
if _, ok := q.Current(); ok {
|
|
t.Error("Current nach Close: want idle")
|
|
}
|
|
if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
|
|
t.Error("Enqueue nach Close: want Fehler")
|
|
}
|
|
q.Close() // idempotent
|
|
}
|
|
|
|
// TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
|
|
func TestEnqueueVergibtIDs(t *testing.T) {
|
|
g := neueSpielerin()
|
|
q := New(g.speak, nil)
|
|
defer q.Close()
|
|
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 20; i++ {
|
|
id, err := q.Enqueue(Item{Text: "x"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(id) < 4 || seen[id] {
|
|
t.Errorf("ID %q zu kurz oder doppelt", id)
|
|
}
|
|
seen[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).
|
|
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) {
|
|
t.Fatal("Queue wird nicht idle")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares 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")
|
|
}
|
|
if err := s.Set(true); err == nil {
|
|
t.Error("Set ohne Pfad: want Fehler")
|
|
}
|
|
|
|
s = MuteStore{Path: path}
|
|
if s.Muted() {
|
|
t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
|
|
}
|
|
|
|
if err := s.Set(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !s.Muted() {
|
|
t.Error("nach Set(true): Muted() = false")
|
|
}
|
|
if b, _ := os.ReadFile(path); string(b) != "true\n" {
|
|
t.Errorf("Datei-Inhalt = %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")
|
|
}
|
|
|
|
// Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
|
|
if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.Muted() {
|
|
t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
|
|
}
|
|
}
|
|
|
|
// TestRateLimiter: Struktur-Semantik (Task 05 verdrahtet sie in den Loop).
|
|
func TestRateLimiter(t *testing.T) {
|
|
// Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
|
|
unlimited := &RateLimiter{}
|
|
for i := 0; i < 3; i++ {
|
|
if !unlimited.Allow() {
|
|
t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
|
|
}
|
|
unlimited.Reserve()
|
|
}
|
|
|
|
// Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
|
|
l := &RateLimiter{Interval: 30 * time.Millisecond}
|
|
if !l.Allow() {
|
|
t.Fatal("frisches Limit: Allow = false")
|
|
}
|
|
l.Reserve()
|
|
if l.Allow() {
|
|
t.Fatal("sofort nach Reserve: Allow = true, want false")
|
|
}
|
|
if next := l.Next(); time.Now().After(next) {
|
|
t.Errorf("Next() = %v liegt in der Vergangenheit", next)
|
|
}
|
|
time.Sleep(40 * time.Millisecond)
|
|
if !l.Allow() {
|
|
t.Fatal("nach Interval: Allow = false, want true")
|
|
}
|
|
}
|