149 lines
5.0 KiB
Go
149 lines
5.0 KiB
Go
// ©AngelaMos | 2026
|
|
// cluster_test.go
|
|
|
|
package cluster
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
const bigWindow = 1 << 40
|
|
|
|
func sizes(clusters []Cluster) []int {
|
|
out := make([]int, len(clusters))
|
|
for i, c := range clusters {
|
|
out[i] = len(c.Members)
|
|
}
|
|
sort.Ints(out)
|
|
return out
|
|
}
|
|
|
|
func findCluster(clusters []Cluster, id int64) Cluster {
|
|
for _, c := range clusters {
|
|
for _, m := range c.Members {
|
|
if m == id {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
return Cluster{}
|
|
}
|
|
|
|
func TestComputeTitleSimilarityGroups(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "Acme Corp data breach exposes millions", Time: 100},
|
|
{ID: 2, SourceID: 2, Title: "Acme Corp data breach exposed millions", Time: 200},
|
|
{ID: 3, SourceID: 3, Title: "Acme Corp data breach hits millions", Time: 300},
|
|
{ID: 4, SourceID: 4, Title: "Linux kernel 7 released with new scheduler", Time: 400},
|
|
}
|
|
clusters := Compute(items, 0.6, bigWindow)
|
|
|
|
if got := sizes(clusters); len(got) != 2 || got[0] != 1 || got[1] != 3 {
|
|
t.Fatalf("cluster sizes = %v, want [1 3]", got)
|
|
}
|
|
c := findCluster(clusters, 1)
|
|
if len(c.Members) != 3 {
|
|
t.Errorf("article 1 cluster size = %d, want 3", len(c.Members))
|
|
}
|
|
if c.SourceCount != 3 {
|
|
t.Errorf("SourceCount = %d, want 3 (three distinct outlets)", c.SourceCount)
|
|
}
|
|
if c.Key != "1" {
|
|
t.Errorf("cluster key = %q, want 1 (earliest article)", c.Key)
|
|
}
|
|
if c.FirstSeen != 100 || c.LastSeen != 300 {
|
|
t.Errorf("span = [%d,%d], want [100,300]", c.FirstSeen, c.LastSeen)
|
|
}
|
|
}
|
|
|
|
func TestComputeSameSourceTitlesDoNotMerge(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "CISA Adds One Known Exploited Vulnerability to Catalog", Time: 100},
|
|
{ID: 2, SourceID: 1, Title: "CISA Adds One Known Exploited Vulnerability to Catalog", Time: 200},
|
|
}
|
|
if got := sizes(Compute(items, 0.6, bigWindow)); len(got) != 2 {
|
|
t.Errorf("same-source identical titles sizes = %v, want [1 1] (clustering is cross-outlet)", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeSharedCVEJoinsRegardlessOfSource(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "Router vendor patches critical flaw", Time: 100, CVEs: []string{"CVE-2026-1111"}},
|
|
{ID: 2, SourceID: 1, Title: "Enterprise gear gets emergency update", Time: 200, CVEs: []string{"CVE-2026-1111"}},
|
|
{ID: 3, SourceID: 2, Title: "Totally unrelated privacy story", Time: 300, CVEs: []string{"CVE-2026-9999"}},
|
|
}
|
|
clusters := Compute(items, 0.6, bigWindow)
|
|
if got := sizes(clusters); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
|
t.Fatalf("cluster sizes = %v, want [1 2]", got)
|
|
}
|
|
joined := findCluster(clusters, 1)
|
|
if len(joined.Members) != 2 {
|
|
t.Error("shared-CVE articles should join even from the same source")
|
|
}
|
|
if joined.SourceCount != 1 {
|
|
t.Errorf("SourceCount = %d, want 1 (a same-source cluster is NOT multi-source)", joined.SourceCount)
|
|
}
|
|
}
|
|
|
|
func TestComputeThresholdBoundary(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "alpha bravo charlie delta", Time: 100},
|
|
{ID: 2, SourceID: 2, Title: "alpha bravo charlie echo", Time: 200},
|
|
}
|
|
if got := sizes(Compute(items, 0.6, bigWindow)); len(got) != 1 || got[0] != 2 {
|
|
t.Errorf("at threshold 0.6 (jaccard==0.6) sizes = %v, want [2] (>= joins)", got)
|
|
}
|
|
if got := sizes(Compute(items, 0.61, bigWindow)); len(got) != 2 {
|
|
t.Errorf("at threshold 0.61 sizes = %v, want [1 1] (below threshold)", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeWindowSeparates(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "identical breaking headline text", Time: 0},
|
|
{ID: 2, SourceID: 2, Title: "identical breaking headline text", Time: 200},
|
|
}
|
|
if got := sizes(Compute(items, 0.6, 100)); len(got) != 2 {
|
|
t.Errorf("outside window sizes = %v, want [1 1] (time gap > window)", got)
|
|
}
|
|
if got := sizes(Compute(items, 0.6, 300)); len(got) != 1 || got[0] != 2 {
|
|
t.Errorf("inside window sizes = %v, want [2]", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeKeyIsEarliestByTime(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 10, SourceID: 1, Title: "shared story about the incident", Time: 500},
|
|
{ID: 20, SourceID: 2, Title: "shared story about the incident", Time: 100},
|
|
}
|
|
clusters := Compute(items, 0.6, bigWindow)
|
|
if len(clusters) != 1 {
|
|
t.Fatalf("clusters = %d, want 1", len(clusters))
|
|
}
|
|
if clusters[0].Key != "20" {
|
|
t.Errorf("key = %q, want 20 (earliest by time, not lowest id)", clusters[0].Key)
|
|
}
|
|
if clusters[0].FirstSeen != 100 || clusters[0].LastSeen != 500 {
|
|
t.Errorf("span = [%d,%d], want [100,500]", clusters[0].FirstSeen, clusters[0].LastSeen)
|
|
}
|
|
}
|
|
|
|
func TestComputeTransitiveChain(t *testing.T) {
|
|
items := []Item{
|
|
{ID: 1, SourceID: 1, Title: "one two three four five", Time: 100},
|
|
{ID: 2, SourceID: 2, Title: "one two three four six", Time: 100},
|
|
{ID: 3, SourceID: 3, Title: "one two three six five", Time: 100},
|
|
}
|
|
clusters := Compute(items, 0.6, bigWindow)
|
|
if len(clusters) != 1 || len(clusters[0].Members) != 3 {
|
|
t.Errorf("transitive similarity should merge all three, got %d clusters", len(clusters))
|
|
}
|
|
}
|
|
|
|
func TestComputeEmpty(t *testing.T) {
|
|
if c := Compute(nil, 0.6, bigWindow); len(c) != 0 {
|
|
t.Errorf("empty input -> %d clusters, want 0", len(c))
|
|
}
|
|
}
|