Files
Pete/internal/scheduler/roundrobin_test.go
prosolis 87906719fa Round-robin: rotate by channel instead of by source
Source-keyed rotation skewed toward whichever channel had the most
feeds (4 of 7 sources routed to politics, so politics dominated the
rotation). Channel-keyed rotation guarantees variety regardless of
feed counts.

Schema: round_robin_state.last_source -> last_channel, added via
addColumnIfMissing so existing DBs migrate in place.
2026-05-24 19:31:27 -07:00

186 lines
5.2 KiB
Go

package scheduler
import (
"path/filepath"
"testing"
"pete/internal/poster"
"pete/internal/storage"
)
type fakeQueue struct {
items []poster.QueueItem
}
func (f *fakeQueue) Enqueue(item poster.QueueItem) {
f.items = append(f.items, item)
}
func setupTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
if err := storage.Init(dbPath); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
}
func insertPostable(t *testing.T, guid, source, channel string, seenAt int64) {
t.Helper()
if err := storage.InsertStory(&storage.Story{
GUID: guid,
Headline: "Headline for " + guid,
Lede: "Lede for " + guid,
ArticleURL: "https://example.com/" + guid,
Source: source,
FeedHint: "tech",
SeenAt: seenAt,
}); err != nil {
t.Fatalf("InsertStory(%s): %v", guid, err)
}
storage.MarkClassified(guid, channel, "[]")
}
func TestNew_EmptyChannels_ReturnsNil(t *testing.T) {
if New(nil, 4, &fakeQueue{}) != nil {
t.Error("expected nil when no channels are given")
}
}
func TestNew_SortsChannels(t *testing.T) {
rr := New([]string{"tech", "gaming", "politics"}, 4, &fakeQueue{})
if rr == nil {
t.Fatal("expected non-nil RoundRobin")
}
want := []string{"gaming", "politics", "tech"}
for i, ch := range rr.channels {
if ch != want[i] {
t.Errorf("channel[%d]=%s, want %s", i, ch, want[i])
}
}
}
func TestTick_PicksNewestPostableFromFirstChannel(t *testing.T) {
setupTestDB(t)
insertPostable(t, "g-old", "TimeExt", "gaming", 100)
insertPostable(t, "g-new", "TimeExt", "gaming", 200)
insertPostable(t, "p-1", "GuardianPolitics", "politics", 150)
fq := &fakeQueue{}
rr := New([]string{"gaming", "politics"}, 4, fq)
rr.tick()
if len(fq.items) != 1 {
t.Fatalf("expected 1 enqueued, got %d", len(fq.items))
}
if fq.items[0].GUID != "g-new" {
t.Errorf("expected newest from gaming (g-new), got %s", fq.items[0].GUID)
}
lastChannel, _, _ := storage.GetRoundRobinState()
if lastChannel != "gaming" {
t.Errorf("expected last_channel=gaming, got %s", lastChannel)
}
}
func TestTick_SkipsEmptyAndAdvancesPointer(t *testing.T) {
setupTestDB(t)
// Pretend we just posted in gaming; next tick should start at politics, which
// is empty, so it should skip to tech.
storage.SetRoundRobinState("gaming", 0)
insertPostable(t, "t-1", "GuardianTech", "tech", 100)
fq := &fakeQueue{}
rr := New([]string{"gaming", "politics", "tech"}, 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "t-1" {
t.Fatalf("expected t-1 to be enqueued, got %+v", fq.items)
}
lastChannel, _, _ := storage.GetRoundRobinState()
if lastChannel != "tech" {
t.Errorf("expected last_channel=tech after skipping politics, got %s", lastChannel)
}
}
func TestTick_NothingPostable_LeavesPointerUnchanged(t *testing.T) {
setupTestDB(t)
storage.SetRoundRobinState("gaming", 1000)
fq := &fakeQueue{}
rr := New([]string{"gaming", "tech"}, 4, fq)
rr.tick()
if len(fq.items) != 0 {
t.Fatalf("expected 0 enqueued, got %d", len(fq.items))
}
lastChannel, lastTickAt, _ := storage.GetRoundRobinState()
if lastChannel != "gaming" {
t.Errorf("expected last_channel unchanged=gaming, got %s", lastChannel)
}
if lastTickAt <= 1000 {
t.Errorf("expected last_tick_at to advance past 1000, got %d", lastTickAt)
}
}
func TestTick_WrapsAroundFromLastChannel(t *testing.T) {
setupTestDB(t)
// Sorted order is [gaming, politics, tech]. last_channel=tech wraps to gaming.
storage.SetRoundRobinState("tech", 0)
insertPostable(t, "g-1", "TimeExt", "gaming", 100)
insertPostable(t, "t-1", "GuardianTech", "tech", 200) // tech is newer, but rotation must pick gaming
fq := &fakeQueue{}
rr := New([]string{"gaming", "politics", "tech"}, 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "g-1" {
t.Fatalf("expected g-1 (wraparound), got %+v", fq.items)
}
}
func TestTick_DiscardedAndDuplicateExcluded(t *testing.T) {
setupTestDB(t)
insertPostable(t, "t-discard", "GuardianTech", "_discarded", 200)
insertPostable(t, "t-dup", "GuardianTech", "_duplicate", 150)
insertPostable(t, "t-real", "GuardianTech", "tech", 100)
fq := &fakeQueue{}
rr := New([]string{"tech"}, 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "t-real" {
t.Fatalf("expected t-real (sentinels excluded), got %+v", fq.items)
}
}
func TestTick_AlreadyPostedExcluded(t *testing.T) {
setupTestDB(t)
insertPostable(t, "t-posted", "GuardianTech", "tech", 200)
insertPostable(t, "t-fresh", "GuardianTech", "tech", 100)
storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted")
fq := &fakeQueue{}
rr := New([]string{"tech"}, 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "t-fresh" {
t.Fatalf("expected t-fresh (t-posted already in post_log), got %+v", fq.items)
}
}
func TestTick_UnknownLastChannel_StartsAtIndexZero(t *testing.T) {
setupTestDB(t)
// Stale state pointing at a channel no longer in config.
storage.SetRoundRobinState("retired", 0)
insertPostable(t, "g-1", "TimeExt", "gaming", 100)
insertPostable(t, "t-1", "GuardianTech", "tech", 200)
fq := &fakeQueue{}
rr := New([]string{"gaming", "tech"}, 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "g-1" {
t.Fatalf("expected g-1 (start at idx 0 when last_channel unknown), got %+v", fq.items)
}
}