Files
claude-mnemonic/internal/graph/graph_test.go
T
lukaszraczylo f07875ee82 fix: plugin no longer vanishes after Claude Code updates
Root cause: plugin registered as directory source in known_marketplaces.json,
which gets wiped on CLI updates. Now registers in extraKnownMarketplaces
(settings.json) as a GitHub source — same mechanism caveman/context-mode use.

Binaries install to ~/.claude-mnemonic/bin/ instead of the Claude-managed
plugins directory. Thin wrapper scripts in the repo let the marketplace
clone find them. Nothing gets cleaned up when Claude refreshes its cache.

Also fixed along the way:
- ONNX Runtime 1.24.3 → 1.26.0 (API v25 mismatch broke all embedding tests)
- Vector client leaked on DB reinit, processQueue had a race on sessionManager
- reloadConfig called os.Exit(0) bypassing graceful shutdown
- Removed dead QueryRowWithTimeout that leaked contexts
- Added tests for graph/watcher/maintenance/update (all were at 0%)
2026-05-24 01:56:54 +01:00

675 lines
20 KiB
Go

//go:build fts5
package graph
import (
"context"
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---- helpers ----------------------------------------------------------------
func makeObs(id int64, sessionID string, concepts, filesRead, filesModified []string) *models.Observation {
return &models.Observation{
ID: id,
SDKSessionID: sessionID,
Title: sql.NullString{String: "title", Valid: true},
Project: "test-project",
Type: models.ObsTypeDecision,
Concepts: concepts,
FilesRead: filesRead,
FilesModified: filesModified,
CreatedAtEpoch: time.Now().UnixMilli(),
}
}
// ---- ObservationGraph -------------------------------------------------------
func TestNewObservationGraph_Empty(t *testing.T) {
g := NewObservationGraph()
require.NotNil(t, g)
stats := g.Stats()
assert.Equal(t, 0, stats.NodeCount)
assert.Equal(t, 0, stats.EdgeCount)
}
func TestAddNode_StoresAndRetrieves(t *testing.T) {
g := NewObservationGraph()
node := &Node{
ID: 42,
Degree: 0,
Metadata: NodeMetadata{
Project: "proj",
Type: "decision",
Title: "test node",
},
}
g.AddNode(node)
got, err := g.GetNode(42)
require.NoError(t, err)
assert.Equal(t, int64(42), got.ID)
assert.Equal(t, "test node", got.Metadata.Title)
}
func TestAddNode_OverwritesExisting(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "old"}})
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "new"}})
got, err := g.GetNode(1)
require.NoError(t, err)
assert.Equal(t, "new", got.Metadata.Title)
}
func TestGetNode_NotFound(t *testing.T) {
g := NewObservationGraph()
_, err := g.GetNode(999)
assert.Error(t, err)
}
func TestAddEdge_UpdatesDegree(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
g.AddNode(&Node{ID: 2})
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
n1, _ := g.GetNode(1)
n2, _ := g.GetNode(2)
assert.Equal(t, 1, n1.Degree)
assert.Equal(t, 1, n2.Degree)
}
func TestAddEdge_MissingNodesDontPanic(t *testing.T) {
g := NewObservationGraph()
// Adding edge referencing non-existent nodes must not panic
assert.NotPanics(t, func() {
g.AddEdge(Edge{FromID: 100, ToID: 200, Relation: RelationConcept, Weight: 0.5})
})
}
// ---- BuildCSR / GetNeighbors ------------------------------------------------
func TestBuildCSR_NoNodes_ReturnsError(t *testing.T) {
g := NewObservationGraph()
err := g.BuildCSR()
assert.Error(t, err)
}
func TestGetNeighbors_AfterBuildCSR(t *testing.T) {
g := NewObservationGraph()
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
require.NoError(t, g.BuildCSR())
neighbors, weights, err := g.GetNeighbors(1)
require.NoError(t, err)
assert.Len(t, neighbors, 2)
assert.Len(t, weights, 2)
}
func TestGetNeighbors_NodeWithNoOutgoingEdges(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
g.AddNode(&Node{ID: 2})
// Edge only from 2 → 1; node 2 is a leaf from 1's perspective
g.AddEdge(Edge{FromID: 2, ToID: 1, Relation: RelationTemporal, Weight: 0.8})
require.NoError(t, g.BuildCSR())
neighbors, weights, err := g.GetNeighbors(1)
require.NoError(t, err)
assert.Empty(t, neighbors)
assert.Empty(t, weights)
}
func TestGetNeighbors_NodeNotInGraph(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
require.NoError(t, g.BuildCSR())
_, _, err := g.GetNeighbors(999)
assert.Error(t, err)
}
// ---- FindHubs ---------------------------------------------------------------
func TestFindHubs_EmptyGraph(t *testing.T) {
g := NewObservationGraph()
hubs := g.FindHubs(0.1)
assert.Nil(t, hubs)
}
func TestFindHubs_IdentifiesHighDegreeNodes(t *testing.T) {
g := NewObservationGraph()
// Node 1 connected to everyone else → hub
for id := int64(1); id <= 5; id++ {
g.AddNode(&Node{ID: id})
}
for id := int64(2); id <= 5; id++ {
g.AddEdge(Edge{FromID: 1, ToID: id, Relation: RelationConcept, Weight: 0.5})
}
hubs := g.FindHubs(0.2) // top 20%
assert.Contains(t, hubs, int64(1))
}
func TestFindHubs_Percentile100_ReturnsEmpty(t *testing.T) {
// percentile=1.0 → cutoff = ceil(N * (1 - 1.0)) = ceil(0) = 0 → no hubs
g := NewObservationGraph()
for id := int64(1); id <= 4; id++ {
g.AddNode(&Node{ID: id})
}
hubs := g.FindHubs(1.0)
assert.Empty(t, hubs)
}
func TestFindHubs_Percentile0_ReturnsAllNodes(t *testing.T) {
// percentile=0.0 → cutoff = ceil(N * 1.0) = N → all nodes returned
g := NewObservationGraph()
for id := int64(1); id <= 4; id++ {
g.AddNode(&Node{ID: id})
}
hubs := g.FindHubs(0.0)
assert.Len(t, hubs, 4)
}
// ---- Stats ------------------------------------------------------------------
func TestStats_EdgeTypesCounted(t *testing.T) {
g := NewObservationGraph()
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
g.AddEdge(Edge{FromID: 2, ToID: 3, Relation: RelationConcept, Weight: 0.6})
stats := g.Stats()
assert.Equal(t, 3, stats.NodeCount)
assert.Equal(t, 3, stats.EdgeCount)
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
assert.Equal(t, 2, stats.EdgeTypes[RelationConcept])
}
func TestStats_DegreeMetrics(t *testing.T) {
g := NewObservationGraph()
// Node 1: degree 2, nodes 2,3: degree 1 each
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationTemporal, Weight: 0.8})
stats := g.Stats()
assert.Equal(t, 2, stats.MaxDegree)
assert.Equal(t, 1, stats.MinDegree)
assert.InDelta(t, 4.0/3.0, stats.AvgDegree, 0.001)
}
// ---- BuildFromObservations --------------------------------------------------
func TestBuildFromObservations_SingleObservation_ReturnsError(t *testing.T) {
obs := []*models.Observation{makeObs(1, "s1", nil, nil, nil)}
// Single observation: DetectEdges returns nil, BuildCSR errors (no nodes never happens
// since node was added — but CSR build will succeed with 1 node and 0 edges).
g, err := BuildFromObservations(context.Background(), obs)
// With 1 node, BuildCSR succeeds (nodes exist); no edges → valid graph.
require.NoError(t, err)
require.NotNil(t, g)
stats := g.Stats()
assert.Equal(t, 1, stats.NodeCount)
assert.Equal(t, 0, stats.EdgeCount)
}
func TestBuildFromObservations_SetsNodeMetadata(t *testing.T) {
obs := []*models.Observation{
{
ID: 7,
SDKSessionID: "sess",
Project: "myproject",
Type: models.ObsTypeFeature,
Title: sql.NullString{String: "feature title", Valid: true},
IsSuperseded: true,
CreatedAtEpoch: time.Now().UnixMilli(),
},
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
node, err := g.GetNode(7)
require.NoError(t, err)
assert.Equal(t, "myproject", node.Metadata.Project)
assert.Equal(t, "feature title", node.Metadata.Title)
assert.Equal(t, string(models.ObsTypeFeature), node.Metadata.Type)
assert.True(t, node.Metadata.IsSuperseded)
}
func TestBuildFromObservations_TitleMissing_EmptyString(t *testing.T) {
obs := []*models.Observation{
{
ID: 3,
SDKSessionID: "s",
Title: sql.NullString{Valid: false},
CreatedAtEpoch: time.Now().UnixMilli(),
},
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
node, err := g.GetNode(3)
require.NoError(t, err)
assert.Equal(t, "", node.Metadata.Title)
}
func TestBuildFromObservations_WithTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "sess-a", nil, nil, nil),
makeObs(2, "sess-a", nil, nil, nil),
makeObs(3, "sess-b", nil, nil, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
assert.Equal(t, 3, stats.NodeCount)
// obs 1 and 2 share session → 1 temporal edge
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
}
func TestBuildFromObservations_WithConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
makeObs(2, "s2", []string{"security"}, nil, nil),
makeObs(3, "s3", []string{"unrelated"}, nil, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
// obs 1 and 2 share "security"
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationConcept], 1)
}
func TestBuildFromObservations_WithFileOverlapEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"pkg/foo.go", "pkg/bar.go"}, nil),
makeObs(2, "s2", nil, []string{"pkg/foo.go", "pkg/baz.go"}, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
// Jaccard({foo,bar},{foo,baz}) = 1/3 ≈ 0.333 > MinFileOverlapForEdge(0.3)
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationFileOverlap], 1)
}
// ---- RelationType.String() --------------------------------------------------
func TestRelationType_String(t *testing.T) {
cases := []struct {
rt RelationType
want string
}{
{RelationFileOverlap, "file_overlap"},
{RelationSemantic, "semantic"},
{RelationTemporal, "temporal"},
{RelationConcept, "concept"},
{RelationType(99), "unknown"},
}
for _, tc := range cases {
t.Run(tc.want, func(t *testing.T) {
assert.Equal(t, tc.want, tc.rt.String())
})
}
}
// ---- DetectEdges (edge_detector.go) ----------------------------------------
func TestDetectEdges_LessThanTwo_ReturnsNil(t *testing.T) {
edges, err := DetectEdges(context.Background(), nil)
assert.NoError(t, err)
assert.Nil(t, edges)
edges, err = DetectEdges(context.Background(), []*models.Observation{makeObs(1, "s", nil, nil, nil)})
assert.NoError(t, err)
assert.Nil(t, edges)
}
func TestDetectEdges_SameSession_CreatesTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "session-x", nil, nil, nil),
makeObs(2, "session-x", nil, nil, nil),
makeObs(3, "session-x", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
var temporal []Edge
for _, e := range edges {
if e.Relation == RelationTemporal {
temporal = append(temporal, e)
}
}
// Consecutive pairs: (1,2) and (2,3)
assert.Len(t, temporal, 2)
assert.InDelta(t, 0.8, temporal[0].Weight, 0.001)
}
func TestDetectEdges_DifferentSessions_NoTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "sess-a", nil, nil, nil),
makeObs(2, "sess-b", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
for _, e := range edges {
assert.NotEqual(t, RelationTemporal, e.Relation)
}
}
func TestDetectEdges_EmptySessionID_NoTemporalEdge(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "", nil, nil, nil),
makeObs(2, "", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
for _, e := range edges {
assert.NotEqual(t, RelationTemporal, e.Relation)
}
}
func TestDetectEdges_SharedConcepts_CreatesConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", []string{"performance", "caching"}, nil, nil),
makeObs(2, "s2", []string{"performance"}, nil, nil),
makeObs(3, "s3", []string{"caching"}, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
conceptEdges := filterByRelation(edges, RelationConcept)
// obs1↔obs2 (performance), obs1↔obs3 (caching) → 2 concept edges
assert.Len(t, conceptEdges, 2)
}
func TestDetectEdges_NoConcepts_NoConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, nil, nil),
makeObs(2, "s2", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
assert.Empty(t, filterByRelation(edges, RelationConcept))
}
func TestDetectEdges_FileOverlap_AboveThreshold_CreatesEdge(t *testing.T) {
// Jaccard 2/3 ≈ 0.667 > 0.3 threshold
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go"}, nil),
makeObs(2, "s2", nil, []string{"a.go", "b.go", "d.go"}, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
fileEdges := filterByRelation(edges, RelationFileOverlap)
require.Len(t, fileEdges, 1)
assert.InDelta(t, 2.0/4.0, float64(fileEdges[0].Weight), 0.01)
}
func TestDetectEdges_FileOverlap_BelowThreshold_NoEdge(t *testing.T) {
// Jaccard 1/9 ≈ 0.11 < 0.3 threshold
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go", "d.go", "e.go"}, nil),
makeObs(2, "s2", nil, []string{"a.go", "f.go", "g.go", "h.go", "i.go"}, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
assert.Empty(t, filterByRelation(edges, RelationFileOverlap))
}
func TestDetectEdges_FilesModified_CountsForOverlap(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, nil, []string{"pkg/core.go", "pkg/util.go"}),
makeObs(2, "s2", nil, []string{"pkg/core.go"}, []string{"pkg/util.go"}),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
fileEdges := filterByRelation(edges, RelationFileOverlap)
assert.NotEmpty(t, fileEdges)
}
func TestDetectEdges_NoEdgeDuplicates(t *testing.T) {
// Same pair via two concepts → only one concept edge
obs := []*models.Observation{
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
makeObs(2, "s2", []string{"security", "auth"}, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
conceptEdges := filterByRelation(edges, RelationConcept)
// Both share security and auth, but deduplication should keep only 1 edge per pair per call
// The seen map deduplicates: only first concept that creates the pair wins
assert.Len(t, conceptEdges, 1)
}
// ---- calculateFileOverlap ---------------------------------------------------
func TestCalculateFileOverlap_DisjointSets_Zero(t *testing.T) {
result := calculateFileOverlap([]string{"a.go", "b.go"}, []string{"c.go", "d.go"})
assert.Equal(t, float32(0.0), result)
}
func TestCalculateFileOverlap_IdenticalSets_One(t *testing.T) {
files := []string{"a.go", "b.go", "c.go"}
result := calculateFileOverlap(files, files)
assert.InDelta(t, 1.0, float64(result), 0.001)
}
func TestCalculateFileOverlap_EmptySlices_Zero(t *testing.T) {
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, []string{"a.go"}))
assert.Equal(t, float32(0.0), calculateFileOverlap([]string{"a.go"}, nil))
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, nil))
}
func TestCalculateFileOverlap_Jaccard_Correct(t *testing.T) {
// {a,b,c} ∩ {b,c,d} = {b,c} → 2/4 = 0.5
result := calculateFileOverlap([]string{"a", "b", "c"}, []string{"b", "c", "d"})
assert.InDelta(t, 0.5, float64(result), 0.001)
}
func TestCalculateFileOverlap_Duplicates_TreatedAsSet(t *testing.T) {
// Duplicates collapse: {a,a,b} → {a,b}; {a,b,b} → {a,b}; Jaccard = 1.0
result := calculateFileOverlap([]string{"a", "a", "b"}, []string{"a", "b", "b"})
assert.InDelta(t, 1.0, float64(result), 0.001)
}
// ---- DetectSemanticEdges ----------------------------------------------------
func TestDetectSemanticEdges_AboveThreshold_CreatesEdge(t *testing.T) {
// Identical vectors → similarity = 1.0 > 0.85
emb := []float32{1.0, 0.0, 0.0}
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
embeddings := map[int64][]float32{1: emb, 2: emb}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
require.Len(t, edges, 1)
assert.Equal(t, RelationSemantic, edges[0].Relation)
assert.InDelta(t, 1.0, float64(edges[0].Weight), 0.001)
}
func TestDetectSemanticEdges_BelowThreshold_NoEdge(t *testing.T) {
// Orthogonal vectors → similarity = 0.0
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
embeddings := map[int64][]float32{
1: {1.0, 0.0, 0.0},
2: {0.0, 1.0, 0.0},
}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
assert.Empty(t, edges)
}
func TestDetectSemanticEdges_MissingEmbedding_Skipped(t *testing.T) {
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
// Only obs 1 has embedding
embeddings := map[int64][]float32{1: {1.0, 0.0, 0.0}}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
assert.Empty(t, edges)
}
func TestDetectSemanticEdges_NoDuplicates(t *testing.T) {
emb := []float32{0.9, 0.1, 0.0}
obs := []*models.Observation{
makeObs(1, "s", nil, nil, nil),
makeObs(2, "s", nil, nil, nil),
makeObs(3, "s", nil, nil, nil),
}
embeddings := map[int64][]float32{1: emb, 2: emb, 3: emb}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
// 3 pairs: (1,2),(1,3),(2,3)
assert.Len(t, edges, 3)
}
// ---- cosineSimilarity -------------------------------------------------------
func TestCosineSimilarity_IdenticalVectors(t *testing.T) {
v := []float32{1.0, 2.0, 3.0}
result := cosineSimilarity(v, v)
assert.InDelta(t, 1.0, float64(result), 0.0001)
}
func TestCosineSimilarity_OppositeVectors(t *testing.T) {
a := []float32{1.0, 0.0}
b := []float32{-1.0, 0.0}
result := cosineSimilarity(a, b)
assert.InDelta(t, -1.0, float64(result), 0.0001)
}
func TestCosineSimilarity_OrthogonalVectors(t *testing.T) {
a := []float32{1.0, 0.0}
b := []float32{0.0, 1.0}
result := cosineSimilarity(a, b)
assert.InDelta(t, 0.0, float64(result), 0.0001)
}
func TestCosineSimilarity_ZeroVector_ReturnsZero(t *testing.T) {
a := []float32{0.0, 0.0}
b := []float32{1.0, 0.0}
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
assert.Equal(t, float32(0.0), cosineSimilarity(b, a))
}
func TestCosineSimilarity_MismatchedLength_ReturnsZero(t *testing.T) {
a := []float32{1.0, 2.0}
b := []float32{1.0, 2.0, 3.0}
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
}
// ---- edgeKey ----------------------------------------------------------------
func TestEdgeKey_Symmetric(t *testing.T) {
// Must produce the same key regardless of order
assert.Equal(t, edgeKey(1, 2), edgeKey(2, 1))
assert.Equal(t, edgeKey(100, 5), edgeKey(5, 100))
}
func TestEdgeKey_DifferentPairs_DifferentKeys(t *testing.T) {
assert.NotEqual(t, edgeKey(1, 2), edgeKey(1, 3))
assert.NotEqual(t, edgeKey(1, 2), edgeKey(2, 3))
}
// ---- pruneEdges -------------------------------------------------------------
func TestPruneEdges_BelowLimit_NoChange(t *testing.T) {
edges := []Edge{
{FromID: 1, ToID: 2, Weight: 0.9},
{FromID: 1, ToID: 3, Weight: 0.7},
}
pruned := pruneEdges(edges, 5)
assert.Len(t, pruned, 2)
}
func TestPruneEdges_ZeroLimit_ReturnsAll(t *testing.T) {
edges := []Edge{{FromID: 1, ToID: 2, Weight: 0.5}}
pruned := pruneEdges(edges, 0)
assert.Len(t, pruned, 1)
}
func TestPruneEdges_KeepsHighWeightEdges(t *testing.T) {
// Node 1 gets 4 edges, limit is 2 → only the 2 heaviest should survive
edges := []Edge{
{FromID: 1, ToID: 2, Weight: 0.9},
{FromID: 1, ToID: 3, Weight: 0.8},
{FromID: 1, ToID: 4, Weight: 0.3},
{FromID: 1, ToID: 5, Weight: 0.1},
}
pruned := pruneEdges(edges, 2)
weights := make([]float32, len(pruned))
for i, e := range pruned {
weights[i] = e.Weight
}
assert.Contains(t, weights, float32(0.9))
assert.Contains(t, weights, float32(0.8))
}
// ---- sortEdgesByWeight ------------------------------------------------------
func TestSortEdgesByWeight_DescendingOrder(t *testing.T) {
edges := []Edge{
{Weight: 0.3},
{Weight: 0.9},
{Weight: 0.1},
{Weight: 0.7},
}
sortEdgesByWeight(edges)
for i := 1; i < len(edges); i++ {
assert.GreaterOrEqual(t, edges[i-1].Weight, edges[i].Weight)
}
}
func TestSortEdgesByWeight_EmptySlice_NoPanic(t *testing.T) {
assert.NotPanics(t, func() {
sortEdgesByWeight([]Edge{})
})
}
func TestSortEdgesByWeight_SingleElement_Unchanged(t *testing.T) {
edges := []Edge{{Weight: 0.5}}
sortEdgesByWeight(edges)
assert.Equal(t, float32(0.5), edges[0].Weight)
}
// ---- helpers ----------------------------------------------------------------
func filterByRelation(edges []Edge, rel RelationType) []Edge {
var out []Edge
for _, e := range edges {
if e.Relation == rel {
out = append(out, e)
}
}
return out
}