spreadlab/internal/engine/graph_test.go
Justin Visser 9a392b1860 api: POST /api/scenario returns result plus graph topology (M3 slice 1)
One panel's whole world in one call: effective config in, echoed
config + cascade result + undirected edge list out. Edges are
[from, to] pairs with from < to in deterministic node order;
Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds
the seeded world (~25us) so Result stays lean and /api/comparison
stays untouched. This closes the topology gap the design brief
flagged; the frontend's seeded d3-force layout consumes these pairs.
Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value
type, comparable, and JSON-marshals to [a, b], exactly the wire
shape the spec asks for.
tygo regen includes a fix: engine.Strategy now maps to the generated
Strategy type instead of decaying to 'any' in ScenarioRequest.
Verified live through the dev stack: 7/120 reached, 351 edges.
2026-06-10 15:48:48 +02:00

129 lines
3.3 KiB
Go

package engine
import (
"slices"
"testing"
)
// Table-driven tests are the standard Go shape: a slice of cases, one
// t.Run per case so failures name the case that broke.
func TestGraphAddEdge(t *testing.T) {
tests := []struct {
name string
edges [][2]int // applied in order
wantAdded []bool // expected AddEdge result per edge
wantEdges int // expected NumEdges afterwards
}{
{
name: "simple edges",
edges: [][2]int{{0, 1}, {1, 2}},
wantAdded: []bool{true, true},
wantEdges: 2,
},
{
name: "duplicate ignored both directions",
edges: [][2]int{{0, 1}, {0, 1}, {1, 0}},
wantAdded: []bool{true, false, false},
wantEdges: 1,
},
{
name: "self loop ignored",
edges: [][2]int{{2, 2}},
wantAdded: []bool{false},
wantEdges: 0,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
graph := NewGraph(4)
for edgeIndex, edge := range testCase.edges {
added := graph.AddEdge(edge[0], edge[1])
if added != testCase.wantAdded[edgeIndex] {
t.Errorf("AddEdge(%d, %d) = %v, want %v",
edge[0], edge[1], added, testCase.wantAdded[edgeIndex])
}
}
if got := graph.NumEdges(); got != testCase.wantEdges {
t.Errorf("NumEdges() = %d, want %d", got, testCase.wantEdges)
}
})
}
}
func TestGraphNeighborsAndDegree(t *testing.T) {
graph := NewGraph(4)
graph.AddEdge(0, 1)
graph.AddEdge(0, 2)
graph.AddEdge(0, 3)
if got := graph.Degree(0); got != 3 {
t.Errorf("Degree(0) = %d, want 3", got)
}
if got := graph.Degree(3); got != 1 {
t.Errorf("Degree(3) = %d, want 1", got)
}
// Insertion order is part of the contract (determinism).
if got, want := graph.Neighbors(0), []int{1, 2, 3}; !slices.Equal(got, want) {
t.Errorf("Neighbors(0) = %v, want %v", got, want)
}
if !graph.HasEdge(2, 0) {
t.Error("HasEdge(2, 0) = false, want true (undirected)")
}
if graph.HasEdge(1, 2) {
t.Error("HasEdge(1, 2) = true, want false")
}
}
func TestGraphEdgesListsEveryEdgeOnce(t *testing.T) {
graph := NewGraph(4)
graph.AddEdge(2, 1) // insertion order must not matter; pairs come out from < to
graph.AddEdge(0, 1)
graph.AddEdge(3, 0)
want := [][2]int{{0, 1}, {0, 3}, {1, 2}}
if got := graph.Edges(); !slices.Equal(got, want) {
t.Errorf("Edges() = %v, want %v", got, want)
}
}
func TestGraphEdgesMatchesGeneratedGraph(t *testing.T) {
graph, err := HolmeKim(60, 3, 0.45, newRand(17))
if err != nil {
t.Fatal(err)
}
edges := graph.Edges()
if len(edges) != graph.NumEdges() {
t.Fatalf("len(Edges()) = %d, want NumEdges() = %d", len(edges), graph.NumEdges())
}
for _, edge := range edges {
from, to := edge[0], edge[1]
if from >= to {
t.Errorf("edge %v: want from < to", edge)
}
if to >= graph.NumNodes() {
t.Errorf("edge %v: endpoint out of range", edge)
}
if !graph.HasEdge(from, to) {
t.Errorf("edge %v not present in adjacency", edge)
}
}
}
func TestGraphEdgesDeterministicFromConfig(t *testing.T) {
first, err := GraphEdges(DefaultConfig())
if err != nil {
t.Fatal(err)
}
second, err := GraphEdges(DefaultConfig())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(first, second) {
t.Error("GraphEdges() differs across identical configs")
}
if len(first) == 0 {
t.Error("GraphEdges() returned no edges")
}
}