feat(queue): serialisierte Ansage-Queue mit Prioritäten, now-Sprung, Mute-Store und Rate-Limit-Struktur
- 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)
This commit is contained in:
1 parent
716f6b2fc9
commit
e492baddac
5 files changed
+742
-3
No files matched your search
@@ -0,0 +1,309 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user