a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
532 lines
16 KiB
Go
532 lines
16 KiB
Go
package graph
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"pgregory.net/rapid"
|
|
)
|
|
|
|
func makeNode(id, name string, kind NodeKind, file, lang string) *Node {
|
|
return &Node{
|
|
ID: id,
|
|
Kind: kind,
|
|
Name: name,
|
|
QualName: "pkg." + name,
|
|
FilePath: file,
|
|
StartLine: 1,
|
|
EndLine: 10,
|
|
Language: lang,
|
|
}
|
|
}
|
|
|
|
func TestAddAndGetNode(t *testing.T) {
|
|
g := New()
|
|
n := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go")
|
|
g.AddNode(n)
|
|
|
|
assert.Equal(t, n, g.GetNode("a.go::Foo"))
|
|
assert.Equal(t, n, g.GetNodeByQualName("pkg.Foo"))
|
|
assert.Equal(t, []*Node{n}, g.FindNodesByName("Foo"))
|
|
assert.Equal(t, []*Node{n}, g.GetFileNodes("a.go"))
|
|
assert.Nil(t, g.GetNode("nonexistent"))
|
|
}
|
|
|
|
func TestAddAndGetEdge(t *testing.T) {
|
|
g := New()
|
|
n1 := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go")
|
|
n2 := makeNode("b.go::Bar", "Bar", KindFunction, "b.go", "go")
|
|
g.AddNode(n1)
|
|
g.AddNode(n2)
|
|
|
|
e := &Edge{From: n1.ID, To: n2.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 5}
|
|
g.AddEdge(e)
|
|
|
|
out := g.GetOutEdges(n1.ID)
|
|
require.Len(t, out, 1)
|
|
assert.Equal(t, EdgeCalls, out[0].Kind)
|
|
|
|
in := g.GetInEdges(n2.ID)
|
|
require.Len(t, in, 1)
|
|
assert.Equal(t, n1.ID, in[0].From)
|
|
}
|
|
|
|
func TestEvictFile(t *testing.T) {
|
|
g := New()
|
|
n1 := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go")
|
|
n2 := makeNode("a.go::Bar", "Bar", KindFunction, "a.go", "go")
|
|
n3 := makeNode("b.go::Baz", "Baz", KindFunction, "b.go", "go")
|
|
g.AddNode(n1)
|
|
g.AddNode(n2)
|
|
g.AddNode(n3)
|
|
|
|
g.AddEdge(&Edge{From: n1.ID, To: n3.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 1})
|
|
g.AddEdge(&Edge{From: n3.ID, To: n2.ID, Kind: EdgeCalls, FilePath: "b.go", Line: 2})
|
|
|
|
nodesRm, edgesRm := g.EvictFile("a.go")
|
|
assert.Equal(t, 2, nodesRm)
|
|
assert.Equal(t, 2, edgesRm) // both edges reference evicted node IDs
|
|
|
|
assert.Nil(t, g.GetNode("a.go::Foo"))
|
|
assert.Nil(t, g.GetNode("a.go::Bar"))
|
|
assert.NotNil(t, g.GetNode("b.go::Baz"))
|
|
|
|
// Edge from b.go to a.go::Bar should also be cleaned from inEdges.
|
|
assert.Empty(t, g.GetOutEdges("a.go::Foo"))
|
|
}
|
|
|
|
func TestEvictFile_NoNodes(t *testing.T) {
|
|
g := New()
|
|
n, e := g.EvictFile("nonexistent.go")
|
|
assert.Equal(t, 0, n)
|
|
assert.Equal(t, 0, e)
|
|
}
|
|
|
|
func TestNodeAndEdgeCount(t *testing.T) {
|
|
g := New()
|
|
g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go"))
|
|
g.AddNode(makeNode("b.go::B", "B", KindType, "b.go", "go"))
|
|
g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeReferences, FilePath: "a.go", Line: 1})
|
|
|
|
assert.Equal(t, 2, g.NodeCount())
|
|
assert.Equal(t, 1, g.EdgeCount())
|
|
}
|
|
|
|
func TestStats(t *testing.T) {
|
|
g := New()
|
|
g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go"))
|
|
g.AddNode(makeNode("b.go::B", "B", KindType, "b.go", "go"))
|
|
g.AddNode(makeNode("c.ts::C", "C", KindFunction, "c.ts", "typescript"))
|
|
g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeReferences, FilePath: "a.go", Line: 1})
|
|
|
|
s := g.Stats()
|
|
assert.Equal(t, 3, s.TotalNodes)
|
|
assert.Equal(t, 1, s.TotalEdges)
|
|
assert.Equal(t, 2, s.ByKind["function"])
|
|
assert.Equal(t, 1, s.ByKind["type"])
|
|
assert.Equal(t, 2, s.ByLanguage["go"])
|
|
assert.Equal(t, 1, s.ByLanguage["typescript"])
|
|
}
|
|
|
|
func TestAllNodesAndEdges(t *testing.T) {
|
|
g := New()
|
|
g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go"))
|
|
g.AddNode(makeNode("b.go::B", "B", KindFunction, "b.go", "go"))
|
|
g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeCalls, FilePath: "a.go", Line: 1})
|
|
|
|
assert.Len(t, g.AllNodes(), 2)
|
|
assert.Len(t, g.AllEdges(), 1)
|
|
}
|
|
|
|
func TestConcurrency(t *testing.T) {
|
|
g := New()
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent writers.
|
|
for i := range 50 {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
id := "file.go::" + string(rune('A'+i))
|
|
n := makeNode(id, string(rune('A'+i)), KindFunction, "file.go", "go")
|
|
n.QualName = "" // avoid collision
|
|
g.AddNode(n)
|
|
}(i)
|
|
}
|
|
|
|
// Concurrent readers.
|
|
for range 50 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = g.NodeCount()
|
|
_ = g.GetFileNodes("file.go")
|
|
_ = g.Stats()
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestNodeBrief(t *testing.T) {
|
|
n := &Node{
|
|
ID: "a.go::Foo", Kind: KindFunction, Name: "Foo",
|
|
QualName: "pkg.Foo", FilePath: "a.go", StartLine: 10, EndLine: 20,
|
|
Language: "go", Meta: map[string]any{"signature": "func Foo()"},
|
|
}
|
|
b := n.Brief()
|
|
assert.Equal(t, "a.go::Foo", b["id"])
|
|
assert.Equal(t, "Foo", b["name"])
|
|
assert.Equal(t, NodeKind("function"), b["kind"])
|
|
assert.Equal(t, "a.go", b["file_path"])
|
|
assert.Equal(t, 10, b["start_line"])
|
|
// Should NOT contain meta, qual_name, end_line, language.
|
|
_, hasMeta := b["meta"]
|
|
assert.False(t, hasMeta)
|
|
}
|
|
|
|
func TestValidNodeKind(t *testing.T) {
|
|
assert.True(t, ValidNodeKind(KindFunction))
|
|
assert.True(t, ValidNodeKind(KindFile))
|
|
assert.False(t, ValidNodeKind(NodeKind("unknown")))
|
|
}
|
|
|
|
func makeRepoNode(id, name string, kind NodeKind, file, lang, repo string) *Node {
|
|
return &Node{
|
|
ID: id,
|
|
Kind: kind,
|
|
Name: name,
|
|
QualName: repo + "." + name,
|
|
FilePath: file,
|
|
StartLine: 1,
|
|
EndLine: 10,
|
|
Language: lang,
|
|
RepoPrefix: repo,
|
|
}
|
|
}
|
|
|
|
func TestAddNode_ByRepoIndex(t *testing.T) {
|
|
g := New()
|
|
n1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
n2 := makeRepoNode("repoB/b.go::Bar", "Bar", KindFunction, "repoB/b.go", "go", "repoB")
|
|
n3 := makeRepoNode("repoA/c.go::Baz", "Baz", KindType, "repoA/c.go", "go", "repoA")
|
|
g.AddNode(n1)
|
|
g.AddNode(n2)
|
|
g.AddNode(n3)
|
|
|
|
repoANodes := g.GetRepoNodes("repoA")
|
|
assert.Len(t, repoANodes, 2)
|
|
repoBNodes := g.GetRepoNodes("repoB")
|
|
assert.Len(t, repoBNodes, 1)
|
|
assert.Equal(t, "Bar", repoBNodes[0].Name)
|
|
}
|
|
|
|
func TestAddNode_EmptyRepoPrefix(t *testing.T) {
|
|
g := New()
|
|
n := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go")
|
|
g.AddNode(n)
|
|
|
|
// Nodes without RepoPrefix should not appear in byRepo.
|
|
assert.Empty(t, g.GetRepoNodes(""))
|
|
assert.Empty(t, g.RepoPrefixes())
|
|
}
|
|
|
|
func TestGetRepoNodes_ReturnsCopy(t *testing.T) {
|
|
g := New()
|
|
n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
g.AddNode(n)
|
|
|
|
nodes := g.GetRepoNodes("repoA")
|
|
nodes[0] = nil // mutate the returned slice
|
|
assert.NotNil(t, g.GetRepoNodes("repoA")[0], "GetRepoNodes should return a copy")
|
|
}
|
|
|
|
func TestGetRepoNodes_NotFound(t *testing.T) {
|
|
g := New()
|
|
assert.Empty(t, g.GetRepoNodes("nonexistent"))
|
|
}
|
|
|
|
func TestEvictRepo(t *testing.T) {
|
|
g := New()
|
|
nA1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
nA2 := makeRepoNode("repoA/a.go::Bar", "Bar", KindFunction, "repoA/a.go", "go", "repoA")
|
|
nB1 := makeRepoNode("repoB/b.go::Baz", "Baz", KindFunction, "repoB/b.go", "go", "repoB")
|
|
g.AddNode(nA1)
|
|
g.AddNode(nA2)
|
|
g.AddNode(nB1)
|
|
|
|
// Edges: intra-repoA, cross-repo A→B, intra-repoB self-ref
|
|
g.AddEdge(&Edge{From: nA1.ID, To: nA2.ID, Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 1})
|
|
g.AddEdge(&Edge{From: nA1.ID, To: nB1.ID, Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 2, CrossRepo: true})
|
|
g.AddEdge(&Edge{From: nB1.ID, To: nA2.ID, Kind: EdgeCalls, FilePath: "repoB/b.go", Line: 3, CrossRepo: true})
|
|
|
|
nodesRm, edgesRm := g.EvictRepo("repoA")
|
|
assert.Equal(t, 2, nodesRm)
|
|
assert.Equal(t, 3, edgesRm) // all 3 edges reference repoA nodes
|
|
|
|
// repoA nodes gone.
|
|
assert.Nil(t, g.GetNode("repoA/a.go::Foo"))
|
|
assert.Nil(t, g.GetNode("repoA/a.go::Bar"))
|
|
assert.Empty(t, g.GetRepoNodes("repoA"))
|
|
|
|
// repoB node still present.
|
|
assert.NotNil(t, g.GetNode("repoB/b.go::Baz"))
|
|
assert.Len(t, g.GetRepoNodes("repoB"), 1)
|
|
|
|
// byName cleaned for evicted nodes.
|
|
assert.Empty(t, g.FindNodesByName("Foo"))
|
|
assert.Empty(t, g.FindNodesByName("Bar"))
|
|
assert.Len(t, g.FindNodesByName("Baz"), 1)
|
|
|
|
// byFile cleaned for evicted nodes.
|
|
assert.Empty(t, g.GetFileNodes("repoA/a.go"))
|
|
assert.Len(t, g.GetFileNodes("repoB/b.go"), 1)
|
|
}
|
|
|
|
func TestEvictRepo_NoNodes(t *testing.T) {
|
|
g := New()
|
|
n, e := g.EvictRepo("nonexistent")
|
|
assert.Equal(t, 0, n)
|
|
assert.Equal(t, 0, e)
|
|
}
|
|
|
|
func TestEvictRepo_QualNameCleaned(t *testing.T) {
|
|
g := New()
|
|
n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
g.AddNode(n)
|
|
|
|
assert.NotNil(t, g.GetNodeByQualName("repoA.Foo"))
|
|
g.EvictRepo("repoA")
|
|
assert.Nil(t, g.GetNodeByQualName("repoA.Foo"))
|
|
}
|
|
|
|
func TestRepoStats(t *testing.T) {
|
|
g := New()
|
|
g.AddNode(makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA"))
|
|
g.AddNode(makeRepoNode("repoA/b.go::Bar", "Bar", KindType, "repoA/b.go", "go", "repoA"))
|
|
g.AddNode(makeRepoNode("repoB/c.ts::Baz", "Baz", KindFunction, "repoB/c.ts", "typescript", "repoB"))
|
|
|
|
// Edge from repoA node.
|
|
g.AddEdge(&Edge{From: "repoA/a.go::Foo", To: "repoA/b.go::Bar", Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 1})
|
|
// Cross-repo edge from repoA to repoB.
|
|
g.AddEdge(&Edge{From: "repoA/a.go::Foo", To: "repoB/c.ts::Baz", Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 2, CrossRepo: true})
|
|
|
|
stats := g.RepoStats()
|
|
require.Len(t, stats, 2)
|
|
|
|
sA := stats["repoA"]
|
|
assert.Equal(t, 2, sA.TotalNodes)
|
|
assert.Equal(t, 2, sA.TotalEdges) // both edges originate from repoA
|
|
assert.Equal(t, 1, sA.ByKind["function"])
|
|
assert.Equal(t, 1, sA.ByKind["type"])
|
|
assert.Equal(t, 2, sA.ByLanguage["go"])
|
|
|
|
sB := stats["repoB"]
|
|
assert.Equal(t, 1, sB.TotalNodes)
|
|
assert.Equal(t, 0, sB.TotalEdges) // no edges originate from repoB
|
|
assert.Equal(t, 1, sB.ByKind["function"])
|
|
assert.Equal(t, 1, sB.ByLanguage["typescript"])
|
|
}
|
|
|
|
func TestRepoStats_Empty(t *testing.T) {
|
|
g := New()
|
|
stats := g.RepoStats()
|
|
assert.Empty(t, stats)
|
|
}
|
|
|
|
func TestRepoPrefixes(t *testing.T) {
|
|
g := New()
|
|
g.AddNode(makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA"))
|
|
g.AddNode(makeRepoNode("repoB/b.go::Bar", "Bar", KindFunction, "repoB/b.go", "go", "repoB"))
|
|
g.AddNode(makeRepoNode("repoA/c.go::Baz", "Baz", KindFunction, "repoA/c.go", "go", "repoA"))
|
|
|
|
prefixes := g.RepoPrefixes()
|
|
assert.Len(t, prefixes, 2)
|
|
assert.ElementsMatch(t, []string{"repoA", "repoB"}, prefixes)
|
|
}
|
|
|
|
func TestRepoPrefixes_Empty(t *testing.T) {
|
|
g := New()
|
|
assert.Empty(t, g.RepoPrefixes())
|
|
}
|
|
|
|
func TestEvictFile_CleansByRepoIndex(t *testing.T) {
|
|
g := New()
|
|
n1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
n2 := makeRepoNode("repoA/b.go::Bar", "Bar", KindFunction, "repoA/b.go", "go", "repoA")
|
|
g.AddNode(n1)
|
|
g.AddNode(n2)
|
|
|
|
assert.Len(t, g.GetRepoNodes("repoA"), 2)
|
|
|
|
g.EvictFile("repoA/a.go")
|
|
|
|
// Only n2 should remain in byRepo.
|
|
repoNodes := g.GetRepoNodes("repoA")
|
|
require.Len(t, repoNodes, 1)
|
|
assert.Equal(t, "Bar", repoNodes[0].Name)
|
|
}
|
|
|
|
func TestEvictFile_CleansByRepoIndex_LastNode(t *testing.T) {
|
|
g := New()
|
|
n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")
|
|
g.AddNode(n)
|
|
|
|
g.EvictFile("repoA/a.go")
|
|
|
|
assert.Empty(t, g.GetRepoNodes("repoA"))
|
|
assert.Empty(t, g.RepoPrefixes())
|
|
}
|
|
|
|
// Feature: multi-repo-support, Property 7: Per-repo index correctness
|
|
|
|
// genRepoPrefix generates a short repo prefix like "repoA", "repoB", etc.
|
|
func genRepoPrefix() *rapid.Generator[string] {
|
|
return rapid.Custom(func(t *rapid.T) string {
|
|
return "repo" + rapid.StringMatching(`[A-Z][a-z]{0,4}`).Draw(t, "suffix")
|
|
})
|
|
}
|
|
|
|
// genMultiRepoGraph builds a graph with 2-5 repo prefixes and 1-10 nodes per repo,
|
|
// plus cross-repo edges. Returns the graph and a map of prefix → expected node IDs.
|
|
func genMultiRepoGraph(t *rapid.T) (*Graph, map[string][]string) {
|
|
g := New()
|
|
numRepos := rapid.IntRange(2, 5).Draw(t, "numRepos")
|
|
|
|
// Generate distinct prefixes.
|
|
prefixSet := make(map[string]bool)
|
|
var prefixes []string
|
|
for len(prefixes) < numRepos {
|
|
p := genRepoPrefix().Draw(t, "prefix")
|
|
if !prefixSet[p] {
|
|
prefixSet[p] = true
|
|
prefixes = append(prefixes, p)
|
|
}
|
|
}
|
|
|
|
expected := make(map[string][]string) // prefix → node IDs
|
|
var allNodeIDs []string
|
|
kinds := []NodeKind{KindFunction, KindType, KindMethod, KindVariable}
|
|
|
|
for _, prefix := range prefixes {
|
|
numNodes := rapid.IntRange(1, 10).Draw(t, "numNodes_"+prefix)
|
|
for j := 0; j < numNodes; j++ {
|
|
name := rapid.StringMatching(`[A-Z][a-zA-Z0-9]{1,10}`).Draw(t, "name")
|
|
file := prefix + "/" + rapid.StringMatching(`[a-z]{1,8}`).Draw(t, "file") + ".go"
|
|
id := file + "::" + name
|
|
kind := kinds[rapid.IntRange(0, len(kinds)-1).Draw(t, "kind")]
|
|
n := makeRepoNode(id, name, kind, file, "go", prefix)
|
|
g.AddNode(n)
|
|
expected[prefix] = append(expected[prefix], id)
|
|
allNodeIDs = append(allNodeIDs, id)
|
|
}
|
|
}
|
|
|
|
// Add some edges including cross-repo ones.
|
|
if len(allNodeIDs) >= 2 {
|
|
numEdges := rapid.IntRange(1, len(allNodeIDs)).Draw(t, "numEdges")
|
|
for i := 0; i < numEdges; i++ {
|
|
fromIdx := rapid.IntRange(0, len(allNodeIDs)-1).Draw(t, "fromIdx")
|
|
toIdx := rapid.IntRange(0, len(allNodeIDs)-1).Draw(t, "toIdx")
|
|
if fromIdx == toIdx {
|
|
continue
|
|
}
|
|
fromNode := g.GetNode(allNodeIDs[fromIdx])
|
|
toNode := g.GetNode(allNodeIDs[toIdx])
|
|
crossRepo := fromNode.RepoPrefix != toNode.RepoPrefix
|
|
g.AddEdge(&Edge{
|
|
From: allNodeIDs[fromIdx],
|
|
To: allNodeIDs[toIdx],
|
|
Kind: EdgeCalls,
|
|
FilePath: fromNode.FilePath,
|
|
Line: i + 1,
|
|
CrossRepo: crossRepo,
|
|
})
|
|
}
|
|
}
|
|
|
|
return g, expected
|
|
}
|
|
|
|
// TestPropertyPerRepoIndexCorrectness verifies that GetRepoNodes returns exactly
|
|
// the nodes with matching RepoPrefix, and the union of all GetRepoNodes equals
|
|
// all nodes that have a RepoPrefix.
|
|
func TestPropertyPerRepoIndexCorrectness(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
g, expected := genMultiRepoGraph(rt)
|
|
|
|
// For each prefix, GetRepoNodes must return exactly the matching nodes.
|
|
for prefix, wantIDs := range expected {
|
|
got := g.GetRepoNodes(prefix)
|
|
gotIDs := make([]string, len(got))
|
|
for i, n := range got {
|
|
gotIDs[i] = n.ID
|
|
// Every returned node must have the correct RepoPrefix.
|
|
assert.Equal(rt, prefix, n.RepoPrefix,
|
|
"GetRepoNodes(%q) returned node %q with wrong RepoPrefix %q", prefix, n.ID, n.RepoPrefix)
|
|
}
|
|
assert.ElementsMatch(rt, wantIDs, gotIDs,
|
|
"GetRepoNodes(%q) returned wrong set of node IDs", prefix)
|
|
}
|
|
|
|
// Union of all GetRepoNodes for all prefixes must equal all nodes with a RepoPrefix.
|
|
prefixes := g.RepoPrefixes()
|
|
unionIDs := make(map[string]bool)
|
|
for _, p := range prefixes {
|
|
for _, n := range g.GetRepoNodes(p) {
|
|
unionIDs[n.ID] = true
|
|
}
|
|
}
|
|
|
|
allNodes := g.AllNodes()
|
|
repoNodeIDs := make(map[string]bool)
|
|
for _, n := range allNodes {
|
|
if n.RepoPrefix != "" {
|
|
repoNodeIDs[n.ID] = true
|
|
}
|
|
}
|
|
|
|
assert.Equal(rt, repoNodeIDs, unionIDs,
|
|
"Union of GetRepoNodes for all prefixes must equal all nodes with a RepoPrefix")
|
|
})
|
|
}
|
|
|
|
// Feature: multi-repo-support, Property 8: Repo eviction completeness
|
|
|
|
// TestPropertyRepoEvictionCompleteness verifies that after EvictRepo, zero nodes/edges
|
|
// remain for the evicted prefix, and other repos are unchanged.
|
|
func TestPropertyRepoEvictionCompleteness(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
g, expected := genMultiRepoGraph(rt)
|
|
|
|
// Pick a repo to evict.
|
|
prefixes := g.RepoPrefixes()
|
|
require.NotEmpty(rt, prefixes, "graph must have at least one repo prefix")
|
|
evictPrefix := prefixes[rapid.IntRange(0, len(prefixes)-1).Draw(rt, "evictIdx")]
|
|
|
|
// Record pre-eviction state for other repos.
|
|
otherCounts := make(map[string]int)
|
|
for _, p := range prefixes {
|
|
if p != evictPrefix {
|
|
otherCounts[p] = len(g.GetRepoNodes(p))
|
|
}
|
|
}
|
|
|
|
// Evict.
|
|
nodesRm, _ := g.EvictRepo(evictPrefix)
|
|
assert.Equal(rt, len(expected[evictPrefix]), nodesRm,
|
|
"EvictRepo should remove exactly the expected number of nodes")
|
|
|
|
// Verify GetRepoNodes returns empty for evicted prefix.
|
|
assert.Empty(rt, g.GetRepoNodes(evictPrefix),
|
|
"GetRepoNodes(%q) must be empty after eviction", evictPrefix)
|
|
|
|
// Verify no nodes in AllNodes have the evicted prefix.
|
|
for _, n := range g.AllNodes() {
|
|
assert.NotEqual(rt, evictPrefix, n.RepoPrefix,
|
|
"AllNodes() still contains node %q with evicted prefix %q", n.ID, evictPrefix)
|
|
}
|
|
|
|
// Verify no edges reference evicted node IDs.
|
|
evictedIDs := make(map[string]bool)
|
|
for _, id := range expected[evictPrefix] {
|
|
evictedIDs[id] = true
|
|
}
|
|
for _, e := range g.AllEdges() {
|
|
assert.False(rt, evictedIDs[e.From],
|
|
"Edge from %q → %q still references evicted node", e.From, e.To)
|
|
assert.False(rt, evictedIDs[e.To],
|
|
"Edge from %q → %q still references evicted node", e.From, e.To)
|
|
}
|
|
|
|
// Verify other repos' node counts are unchanged.
|
|
for p, wantCount := range otherCounts {
|
|
gotCount := len(g.GetRepoNodes(p))
|
|
assert.Equal(rt, wantCount, gotCount,
|
|
"Repo %q node count changed after evicting %q", p, evictPrefix)
|
|
}
|
|
})
|
|
}
|