134 lines
3.3 KiB
Go
134 lines
3.3 KiB
Go
// ©AngelaMos | 2026
|
|
// rebuild_test.go
|
|
|
|
package cluster
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
|
)
|
|
|
|
func openStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "cluster.db"))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return st
|
|
}
|
|
|
|
func seedArticles(t *testing.T, st *store.Store) {
|
|
t.Helper()
|
|
titles := []string{
|
|
"Acme Corp data breach exposes millions",
|
|
"Acme Corp data breach exposed millions",
|
|
"Acme Corp data breach hits millions",
|
|
"Linux kernel 7 released with new scheduler",
|
|
}
|
|
for i, title := range titles {
|
|
sourceID, err := st.UpsertSource(store.SourceInput{
|
|
Name: fmt.Sprintf("src%d", i), URL: fmt.Sprintf("https://outlet%d.example/feed", i),
|
|
Type: "rss", Weight: 1, Enabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upsert source %d: %v", i, err)
|
|
}
|
|
if _, err := st.InsertArticle(store.Article{
|
|
SourceID: sourceID,
|
|
CanonicalURL: fmt.Sprintf("https://example.com/a/%d", i),
|
|
ContentHash: fmt.Sprintf("chash-%d", i),
|
|
TitleHash: fmt.Sprintf("thash-%d", i),
|
|
Title: title,
|
|
PublishedAt: int64(100 * (i + 1)),
|
|
FetchedAt: int64(100 * (i + 1)),
|
|
}); err != nil {
|
|
t.Fatalf("insert article %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clusterSizes(t *testing.T, st *store.Store) []int {
|
|
t.Helper()
|
|
rows, err := st.DB().Query(`SELECT size FROM clusters ORDER BY size`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var out []int
|
|
for rows.Next() {
|
|
var n int
|
|
if err := rows.Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func countMembers(t *testing.T, st *store.Store) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM cluster_members`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestRebuildClustersEndToEnd(t *testing.T) {
|
|
st := openStore(t)
|
|
seedArticles(t, st)
|
|
|
|
stats, err := Rebuild(st, 0.6, bigWindow, 0)
|
|
if err != nil {
|
|
t.Fatalf("Rebuild: %v", err)
|
|
}
|
|
if stats.Total != 2 || stats.MultiSource != 1 || stats.LargestSize != 3 {
|
|
t.Fatalf("stats = %+v, want {Total:2 MultiSource:1 LargestSize:3}", stats)
|
|
}
|
|
if got := clusterSizes(t, st); len(got) != 2 || got[0] != 1 || got[1] != 3 {
|
|
t.Errorf("cluster sizes = %v, want [1 3]", got)
|
|
}
|
|
if m := countMembers(t, st); m != 4 {
|
|
t.Errorf("cluster_members = %d, want 4 (every article assigned once)", m)
|
|
}
|
|
}
|
|
|
|
func TestRebuildIsIdempotent(t *testing.T) {
|
|
st := openStore(t)
|
|
seedArticles(t, st)
|
|
|
|
if _, err := Rebuild(st, 0.6, bigWindow, 0); err != nil {
|
|
t.Fatalf("first Rebuild: %v", err)
|
|
}
|
|
stats, err := Rebuild(st, 0.6, bigWindow, 0)
|
|
if err != nil {
|
|
t.Fatalf("second Rebuild: %v", err)
|
|
}
|
|
if stats.Total != 2 {
|
|
t.Errorf("second run clusters = %d, want 2 (rebuild replaces, no accumulation)", stats.Total)
|
|
}
|
|
if m := countMembers(t, st); m != 4 {
|
|
t.Errorf("cluster_members = %d, want 4 after re-run", m)
|
|
}
|
|
}
|
|
|
|
func TestRebuildLookbackExcludesOld(t *testing.T) {
|
|
st := openStore(t)
|
|
seedArticles(t, st)
|
|
|
|
stats, err := Rebuild(st, 0.6, bigWindow, 250)
|
|
if err != nil {
|
|
t.Fatalf("Rebuild: %v", err)
|
|
}
|
|
if stats.Total != 2 {
|
|
t.Errorf("with since=250 only articles at t>=250 cluster, total = %d, want 2", stats.Total)
|
|
}
|
|
if m := countMembers(t, st); m != 2 {
|
|
t.Errorf("members = %d, want 2 (two articles below lookback excluded)", m)
|
|
}
|
|
}
|