diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffabb87..e55f3f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: run: go vet ./... - name: Test (race + coverage) - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + run: go test -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... - name: Fuzz (10s) run: | diff --git a/Makefile b/Makefile index 07789e0..4ccb0e6 100644 --- a/Makefile +++ b/Makefile @@ -54,9 +54,17 @@ race: ## Run tests with race detector .PHONY: cover cover: ## Run tests with race + coverage and summarize - $(GO) test -race -coverprofile=$(COVEROUT) -covermode=atomic ./... + $(GO) test -race -coverprofile=$(COVEROUT) -covermode=atomic ./... @$(GO) tool cover -func=$(COVEROUT) | tail -n 1 +.PHONY: coverfunc +coverfunc: ## Print per-function coverage from $(COVEROUT) + @$(GO) tool cover -func=$(COVEROUT) + +.PHONY: cover-kdtree +cover-kdtree: ## Print coverage details for kdtree.go only + @$(GO) tool cover -func=$(COVEROUT) | grep 'kdtree.go' || true + .PHONY: coverhtml coverhtml: cover ## Generate HTML coverage report at $(COVERHTML) @$(GO) tool cover -html=$(COVEROUT) -o $(COVERHTML) diff --git a/README.md b/README.md index e36b89c..ef121b8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ The repository includes a maintainer-friendly `Makefile` that mirrors CI tasks a - race — run tests with the race detector - cover — run tests with race + coverage (writes `coverage.out` and prints summary) - coverhtml — render HTML coverage report to `coverage.html` +- coverfunc — print per-function coverage (from `coverage.out`) +- cover-kdtree — print coverage details filtered to `kdtree.go` - fuzz — run Go fuzzing for a configurable time (default 10s) matching CI - bench — run benchmarks with `-benchmem` (writes `bench.txt`) - lint — run `golangci-lint` (if installed) diff --git a/examples/dht_ping_1d/example_test.go b/examples/dht_ping_1d/example_test.go new file mode 100644 index 0000000..3f431a0 --- /dev/null +++ b/examples/dht_ping_1d/example_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + poindexter "github.com/Snider/Poindexter" + "testing" +) + +type peer struct { + Addr string + Ping int +} + +// TestExample1D ensures the 1D example logic runs and exercises KDTree paths. +func TestExample1D(t *testing.T) { + // Same toy table as the example + table := []peer{ + {Addr: "peer1.example:4001", Ping: 74}, + {Addr: "peer2.example:4001", Ping: 52}, + {Addr: "peer3.example:4001", Ping: 110}, + {Addr: "peer4.example:4001", Ping: 35}, + {Addr: "peer5.example:4001", Ping: 60}, + {Addr: "peer6.example:4001", Ping: 44}, + } + pts := make([]poindexter.KDPoint[peer], 0, len(table)) + for i, p := range table { + pts = append(pts, poindexter.KDPoint[peer]{ + ID: fmt.Sprintf("peer-%d", i+1), + Coords: []float64{float64(p.Ping)}, + Value: p, + }) + } + kdt, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{})) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + best, d, ok := kdt.Nearest([]float64{0}) + if !ok { + t.Fatalf("no nearest") + } + // Expect the minimum ping (35ms) + if best.Value.Ping != 35 { + t.Fatalf("expected best ping 35ms, got %d", best.Value.Ping) + } + if d <= 0 { + t.Fatalf("expected positive distance, got %v", d) + } +} diff --git a/examples/dht_ping_1d/main_test.go b/examples/dht_ping_1d/main_test.go new file mode 100644 index 0000000..619753a --- /dev/null +++ b/examples/dht_ping_1d/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +// TestExampleMain runs the example's main function to ensure it executes without panic. +// This also allows the example code paths to be included in coverage reports. +func TestExampleMain(t *testing.T) { + main() +} diff --git a/examples/kdtree_2d_ping_hop/example_test.go b/examples/kdtree_2d_ping_hop/example_test.go new file mode 100644 index 0000000..3be3f59 --- /dev/null +++ b/examples/kdtree_2d_ping_hop/example_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + + poindexter "github.com/Snider/Poindexter" +) + +type peer2 struct { + ID string + PingMS float64 + Hops float64 +} + +func TestExample2D(t *testing.T) { + peers := []peer2{ + {ID: "A", PingMS: 22, Hops: 3}, + {ID: "B", PingMS: 34, Hops: 2}, + {ID: "C", PingMS: 15, Hops: 4}, + {ID: "D", PingMS: 55, Hops: 1}, + {ID: "E", PingMS: 18, Hops: 2}, + } + weights := [2]float64{1.0, 1.0} + invert := [2]bool{false, false} + pts, err := poindexter.Build2D( + peers, + func(p peer2) string { return p.ID }, + func(p peer2) float64 { return p.PingMS }, + func(p peer2) float64 { return p.Hops }, + weights, invert, + ) + if err != nil { + t.Fatalf("Build2D err: %v", err) + } + tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{})) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + best, d, ok := tr.Nearest([]float64{0, 0.3}) + if !ok { + t.Fatalf("no nearest") + } + if best.ID == "" { + t.Fatalf("unexpected empty ID") + } + if d < 0 { + t.Fatalf("negative distance: %v", d) + } +} diff --git a/examples/kdtree_2d_ping_hop/main_test.go b/examples/kdtree_2d_ping_hop/main_test.go new file mode 100644 index 0000000..4f4c6c3 --- /dev/null +++ b/examples/kdtree_2d_ping_hop/main_test.go @@ -0,0 +1,7 @@ +package main + +import "testing" + +func TestExample2D_Main(t *testing.T) { + main() +} diff --git a/examples/kdtree_3d_ping_hop_geo/example_test.go b/examples/kdtree_3d_ping_hop_geo/example_test.go new file mode 100644 index 0000000..252096c --- /dev/null +++ b/examples/kdtree_3d_ping_hop_geo/example_test.go @@ -0,0 +1,50 @@ +package main + +import ( + poindexter "github.com/Snider/Poindexter" + "testing" +) + +type peer3test struct { + ID string + PingMS float64 + Hops float64 + GeoKM float64 +} + +func TestExample3D(t *testing.T) { + peers := []peer3test{ + {ID: "A", PingMS: 22, Hops: 3, GeoKM: 1200}, + {ID: "B", PingMS: 34, Hops: 2, GeoKM: 800}, + {ID: "C", PingMS: 15, Hops: 4, GeoKM: 4500}, + {ID: "D", PingMS: 55, Hops: 1, GeoKM: 300}, + {ID: "E", PingMS: 18, Hops: 2, GeoKM: 2200}, + } + weights := [3]float64{1.0, 0.7, 0.3} + invert := [3]bool{false, false, false} + pts, err := poindexter.Build3D( + peers, + func(p peer3test) string { return p.ID }, + func(p peer3test) float64 { return p.PingMS }, + func(p peer3test) float64 { return p.Hops }, + func(p peer3test) float64 { return p.GeoKM }, + weights, invert, + ) + if err != nil { + t.Fatalf("Build3D err: %v", err) + } + tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{})) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + best, d, ok := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.4}) + if !ok { + t.Fatalf("no nearest") + } + if best.ID == "" { + t.Fatalf("unexpected empty ID") + } + if d < 0 { + t.Fatalf("negative distance: %v", d) + } +} diff --git a/examples/kdtree_3d_ping_hop_geo/main_test.go b/examples/kdtree_3d_ping_hop_geo/main_test.go new file mode 100644 index 0000000..a6b0b2f --- /dev/null +++ b/examples/kdtree_3d_ping_hop_geo/main_test.go @@ -0,0 +1,7 @@ +package main + +import "testing" + +func TestExample3D_Main(t *testing.T) { + main() +} diff --git a/examples/kdtree_4d_ping_hop_geo_score/example_test.go b/examples/kdtree_4d_ping_hop_geo_score/example_test.go new file mode 100644 index 0000000..ec7c468 --- /dev/null +++ b/examples/kdtree_4d_ping_hop_geo_score/example_test.go @@ -0,0 +1,52 @@ +package main + +import ( + poindexter "github.com/Snider/Poindexter" + "testing" +) + +type peer4test struct { + ID string + PingMS float64 + Hops float64 + GeoKM float64 + Score float64 +} + +func TestExample4D(t *testing.T) { + peers := []peer4test{ + {ID: "A", PingMS: 22, Hops: 3, GeoKM: 1200, Score: 0.86}, + {ID: "B", PingMS: 34, Hops: 2, GeoKM: 800, Score: 0.91}, + {ID: "C", PingMS: 15, Hops: 4, GeoKM: 4500, Score: 0.70}, + {ID: "D", PingMS: 55, Hops: 1, GeoKM: 300, Score: 0.95}, + {ID: "E", PingMS: 18, Hops: 2, GeoKM: 2200, Score: 0.80}, + } + weights := [4]float64{1.0, 0.7, 0.2, 1.2} + invert := [4]bool{false, false, false, true} + pts, err := poindexter.Build4D( + peers, + func(p peer4test) string { return p.ID }, + func(p peer4test) float64 { return p.PingMS }, + func(p peer4test) float64 { return p.Hops }, + func(p peer4test) float64 { return p.GeoKM }, + func(p peer4test) float64 { return p.Score }, + weights, invert, + ) + if err != nil { + t.Fatalf("Build4D err: %v", err) + } + tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{})) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + best, d, ok := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.3, 0}) + if !ok { + t.Fatalf("no nearest") + } + if best.ID == "" { + t.Fatalf("unexpected empty ID") + } + if d < 0 { + t.Fatalf("negative distance: %v", d) + } +} diff --git a/examples/kdtree_4d_ping_hop_geo_score/main_test.go b/examples/kdtree_4d_ping_hop_geo_score/main_test.go new file mode 100644 index 0000000..19247d6 --- /dev/null +++ b/examples/kdtree_4d_ping_hop_geo_score/main_test.go @@ -0,0 +1,7 @@ +package main + +import "testing" + +func TestExample4D_Main(t *testing.T) { + main() +} diff --git a/kdtree_branches_test.go b/kdtree_branches_test.go new file mode 100644 index 0000000..3ec01e9 --- /dev/null +++ b/kdtree_branches_test.go @@ -0,0 +1,42 @@ +package poindexter + +import "testing" + +func TestKNearest_EdgeCases(t *testing.T) { + pts := []KDPoint[int]{ + {ID: "a", Coords: []float64{0}}, + } + tr, _ := NewKDTree(pts) + // k <= 0 → nil + ns, ds := tr.KNearest([]float64{0}, 0) + if ns != nil || ds != nil { + t.Fatalf("expected nil for k<=0, got %v %v", ns, ds) + } + // query-dim mismatch → nil + ns, ds = tr.KNearest([]float64{0, 1}, 1) + if ns != nil || ds != nil { + t.Fatalf("expected nil for dim mismatch, got %v %v", ns, ds) + } +} + +func TestRadius_QueryDimMismatch(t *testing.T) { + pts := []KDPoint[int]{{ID: "p", Coords: []float64{0}}} + tr, _ := NewKDTree(pts) + ns, ds := tr.Radius([]float64{0, 0}, 1) + if ns != nil || ds != nil { + t.Fatalf("expected nil for dim mismatch, got %v %v", ns, ds) + } +} + +func TestInsert_DimMismatch(t *testing.T) { + tr, _ := NewKDTreeFromDim[int](2) + ok := tr.Insert(KDPoint[int]{ID: "bad", Coords: []float64{0}}) // wrong dim + if ok { + t.Fatalf("expected false on insert with dim mismatch") + } + // inserting with empty ID should succeed and not touch idIndex + ok = tr.Insert(KDPoint[int]{ID: "", Coords: []float64{0, 0}}) + if !ok { + t.Fatalf("expected true on insert with empty ID and matching dim") + } +} diff --git a/kdtree_extra_test.go b/kdtree_extra_test.go new file mode 100644 index 0000000..0f8d4d7 --- /dev/null +++ b/kdtree_extra_test.go @@ -0,0 +1,116 @@ +package poindexter + +import ( + "errors" + "testing" +) + +func TestNewKDTree_Errors(t *testing.T) { + // empty points + if _, err := NewKDTree[string](nil); !errors.Is(err, ErrEmptyPoints) { + t.Fatalf("want ErrEmptyPoints, got %v", err) + } + // zero-dim + pts0 := []KDPoint[string]{{ID: "A", Coords: nil}} + if _, err := NewKDTree(pts0); !errors.Is(err, ErrZeroDim) { + t.Fatalf("want ErrZeroDim, got %v", err) + } + // dim mismatch + ptsDim := []KDPoint[string]{ + {ID: "A", Coords: []float64{0}}, + {ID: "B", Coords: []float64{0, 1}}, + } + if _, err := NewKDTree(ptsDim); !errors.Is(err, ErrDimMismatch) { + t.Fatalf("want ErrDimMismatch, got %v", err) + } + // duplicate IDs + ptsDup := []KDPoint[string]{ + {ID: "X", Coords: []float64{0}}, + {ID: "X", Coords: []float64{1}}, + } + if _, err := NewKDTree(ptsDup); !errors.Is(err, ErrDuplicateID) { + t.Fatalf("want ErrDuplicateID, got %v", err) + } +} + +func TestDeleteByID_NotFound(t *testing.T) { + pts := []KDPoint[int]{ + {ID: "A", Coords: []float64{0}, Value: 1}, + } + tr, err := NewKDTree(pts) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + if tr.DeleteByID("NOPE") { + t.Fatalf("expected false for missing ID") + } +} + +func TestKNearest_KGreaterThanN(t *testing.T) { + pts := []KDPoint[int]{ + {ID: "a", Coords: []float64{0}}, + {ID: "b", Coords: []float64{2}}, + } + tr, _ := NewKDTree(pts) + ns, ds := tr.KNearest([]float64{1}, 5) + if len(ns) != 2 || len(ds) != 2 { + t.Fatalf("want 2 neighbors, got %d", len(ns)) + } + if !(ds[0] <= ds[1]) { + t.Fatalf("distances not sorted: %v", ds) + } +} + +func TestRadius_BoundaryAndZero(t *testing.T) { + pts := []KDPoint[int]{ + {ID: "o", Coords: []float64{0}}, + {ID: "one", Coords: []float64{1}}, + } + tr, _ := NewKDTree(pts, WithMetric(EuclideanDistance{})) + // radius exactly includes point at distance 1 + within, _ := tr.Radius([]float64{0}, 1) + foundOne := false + for _, p := range within { + if p.ID == "one" { + foundOne = true + } + } + if !foundOne { + t.Fatalf("expected to include point at exact radius") + } + // radius zero should include exact match only + within0, _ := tr.Radius([]float64{0}, 0) + if len(within0) == 0 || within0[0].ID != "o" { + t.Fatalf("expected only origin at r=0, got %v", within0) + } +} + +func TestNewKDTreeFromDim_WithMetric_InsertQuery(t *testing.T) { + tr, err := NewKDTreeFromDim[string](2, WithMetric(ManhattanDistance{})) + if err != nil { + t.Fatalf("err: %v", err) + } + ok := tr.Insert(KDPoint[string]{ID: "A", Coords: []float64{0, 0}, Value: "a"}) + if !ok { + t.Fatalf("insert failed") + } + tr.Insert(KDPoint[string]{ID: "B", Coords: []float64{2, 2}, Value: "b"}) + p, d, ok := tr.Nearest([]float64{1, 0}) + if !ok || p.ID != "A" { + t.Fatalf("expected A nearest, got %v", p) + } + if d != 1 { // ManhattanDistance from (1,0) to (0,0) is 1 + t.Fatalf("expected manhattan distance 1, got %v", d) + } +} + +func TestNearest_QueryDimMismatch(t *testing.T) { + pts := []KDPoint[int]{ + {ID: "a", Coords: []float64{0, 0}}, + } + tr, _ := NewKDTree(pts) + _, _, ok := tr.Nearest([]float64{0}) + if ok { + t.Fatalf("expected ok=false for query dim mismatch") + } +} diff --git a/kdtree_morecov_test.go b/kdtree_morecov_test.go new file mode 100644 index 0000000..c4ef4dc --- /dev/null +++ b/kdtree_morecov_test.go @@ -0,0 +1,100 @@ +package poindexter + +import ( + "fmt" + "testing" +) + +func TestInsert_DuplicateID(t *testing.T) { + tr, err := NewKDTreeFromDim[string](1) + if err != nil { + t.Fatalf("err: %v", err) + } + ok := tr.Insert(KDPoint[string]{ID: "X", Coords: []float64{0}}) + if !ok { + t.Fatalf("first insert should succeed") + } + // duplicate ID should fail + if tr.Insert(KDPoint[string]{ID: "X", Coords: []float64{1}}) { + t.Fatalf("expected insert duplicate ID to return false") + } +} + +func TestDeleteByID_SwapDelete(t *testing.T) { + // Arrange 3 points so that deleting the middle triggers swap-delete path + pts := []KDPoint[int]{ + {ID: "A", Coords: []float64{0}}, + {ID: "B", Coords: []float64{1}}, + {ID: "C", Coords: []float64{2}}, + } + tr, err := NewKDTree(pts) + if err != nil { + t.Fatalf("NewKDTree err: %v", err) + } + if !tr.DeleteByID("B") { + t.Fatalf("delete B failed") + } + if tr.Len() != 2 { + t.Fatalf("expected len 2, got %d", tr.Len()) + } + // Ensure B is gone and A/C remain reachable + ids := make(map[string]bool) + for _, q := range [][]float64{{0}, {2}} { + p, _, ok := tr.Nearest(q) + if ok { + ids[p.ID] = true + } + } + if ids["B"] { + t.Fatalf("B should not be present after delete") + } + if !(ids["A"] || ids["C"]) { + t.Fatalf("expected either A or C to be nearest for respective queries: %v", ids) + } +} + +func TestRadius_NegativeReturnsNil(t *testing.T) { + pts := []KDPoint[int]{{ID: "z", Coords: []float64{0}}} + tr, _ := NewKDTree(pts) + ns, ds := tr.Radius([]float64{0}, -1) + if ns != nil || ds != nil { + // Both should be nil on invalid radius + t.Fatalf("expected nil slices on negative radius, got %v %v", ns, ds) + } +} + +func TestNearest_EmptyTree(t *testing.T) { + tr, _ := NewKDTreeFromDim[int](2) + _, _, ok := tr.Nearest([]float64{0, 0}) + if ok { + t.Fatalf("expected ok=false for empty tree") + } +} + +func TestWeightedCosineMetric_ViaKDTree(t *testing.T) { + // Two points oriented differently around the query; ensure call path exercised + type rec struct{ a, b float64 } + items := []rec{{1, 0}, {0, 1}} + weights := []float64{1, 2} + invert := []bool{false, false} + features := []func(rec) float64{ + func(r rec) float64 { return r.a }, + func(r rec) float64 { return r.b }, + } + pts, err := BuildND(items, func(r rec) string { return fmt.Sprintf("%v", r) }, features, weights, invert) + if err != nil { + t.Fatalf("buildND err: %v", err) + } + tr, err := NewKDTree(pts, WithMetric(WeightedCosineDistance{Weights: weights})) + if err != nil { + t.Fatalf("kdt err: %v", err) + } + q := []float64{0.5 * weights[0], 0.5 * weights[1]} // mid direction + _, d, ok := tr.Nearest(q) + if !ok { + t.Fatalf("no nearest") + } + if d < 0 || d > 2 { + t.Fatalf("cosine distance out of bounds: %v", d) + } +}