|
| 1 | +package shards |
| 2 | + |
| 3 | +import ( |
| 4 | + "strings" |
| 5 | + "testing" |
| 6 | + |
| 7 | + "github.com/supermodeltools/cli/internal/api" |
| 8 | +) |
| 9 | + |
| 10 | +// makeRenderCache builds a Cache from a ShardIR for render tests. |
| 11 | +func makeRenderCache(ir *api.ShardIR) *Cache { |
| 12 | + c := NewCache() |
| 13 | + c.Build(ir) |
| 14 | + return c |
| 15 | +} |
| 16 | + |
| 17 | +func shardIR(nodes []api.Node, rels []api.Relationship) *api.ShardIR { |
| 18 | + return &api.ShardIR{ |
| 19 | + Graph: api.ShardGraph{ |
| 20 | + Nodes: nodes, |
| 21 | + Relationships: rels, |
| 22 | + }, |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +// TestRenderCallsSection_Deterministic verifies that renderCallsSection produces |
| 27 | +// the same output regardless of the order relationships were appended to the cache. |
| 28 | +// This catches non-determinism from map iteration or relationship ordering in the |
| 29 | +// API response, which would cause unnecessary shard file rewrites on each run. |
| 30 | +func TestRenderCallsSection_Deterministic(t *testing.T) { |
| 31 | + nodes := []api.Node{ |
| 32 | + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "handle", "filePath": "src/a.go"}}, |
| 33 | + {ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "parse", "filePath": "src/b.go"}}, |
| 34 | + {ID: "fn3", Labels: []string{"Function"}, Properties: map[string]any{"name": "validate", "filePath": "src/c.go"}}, |
| 35 | + } |
| 36 | + |
| 37 | + // Build two equivalent graphs with relationships in reversed order. |
| 38 | + // If rendering is deterministic, both must produce identical output. |
| 39 | + ir1 := shardIR(nodes, []api.Relationship{ |
| 40 | + {ID: "r1", Type: "calls", StartNode: "fn2", EndNode: "fn1"}, |
| 41 | + {ID: "r2", Type: "calls", StartNode: "fn3", EndNode: "fn1"}, |
| 42 | + }) |
| 43 | + ir2 := shardIR(nodes, []api.Relationship{ |
| 44 | + {ID: "r2", Type: "calls", StartNode: "fn3", EndNode: "fn1"}, |
| 45 | + {ID: "r1", Type: "calls", StartNode: "fn2", EndNode: "fn1"}, |
| 46 | + }) |
| 47 | + |
| 48 | + c1 := makeRenderCache(ir1) |
| 49 | + c2 := makeRenderCache(ir2) |
| 50 | + |
| 51 | + out1 := renderCallsSection("src/a.go", c1, "//") |
| 52 | + out2 := renderCallsSection("src/a.go", c2, "//") |
| 53 | + |
| 54 | + if out1 != out2 { |
| 55 | + t.Errorf("renderCallsSection output differs based on relationship order:\ngot1:\n%s\ngot2:\n%s", out1, out2) |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +// TestRenderCalleesSection_Deterministic mirrors TestRenderCallsSection_Deterministic |
| 60 | +// but targets the callee path: a single caller with multiple callees whose relationships |
| 61 | +// appear in reversed order must produce identical output. |
| 62 | +func TestRenderCalleesSection_Deterministic(t *testing.T) { |
| 63 | + nodes := []api.Node{ |
| 64 | + {ID: "fn_caller", Labels: []string{"Function"}, Properties: map[string]any{"name": "dispatch", "filePath": "src/a.go"}}, |
| 65 | + {ID: "fn_c1", Labels: []string{"Function"}, Properties: map[string]any{"name": "alpha", "filePath": "src/b.go"}}, |
| 66 | + {ID: "fn_c2", Labels: []string{"Function"}, Properties: map[string]any{"name": "beta", "filePath": "src/c.go"}}, |
| 67 | + } |
| 68 | + |
| 69 | + ir1 := shardIR(nodes, []api.Relationship{ |
| 70 | + {ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c1"}, |
| 71 | + {ID: "r2", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c2"}, |
| 72 | + }) |
| 73 | + ir2 := shardIR(nodes, []api.Relationship{ |
| 74 | + {ID: "r2", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c2"}, |
| 75 | + {ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c1"}, |
| 76 | + }) |
| 77 | + |
| 78 | + c1 := makeRenderCache(ir1) |
| 79 | + c2 := makeRenderCache(ir2) |
| 80 | + |
| 81 | + out1 := renderCallsSection("src/a.go", c1, "//") |
| 82 | + out2 := renderCallsSection("src/a.go", c2, "//") |
| 83 | + |
| 84 | + if out1 != out2 { |
| 85 | + t.Errorf("callee output differs based on relationship order:\ngot1:\n%s\ngot2:\n%s", out1, out2) |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +// TestRenderCallsSection_SameNameFunctions ensures that two functions with the same |
| 90 | +// name (but different IDs, e.g. methods on different types) are ordered by ID when |
| 91 | +// they share a name, preventing non-determinism from the unstable sort. |
| 92 | +func TestRenderCallsSection_SameNameFunctions(t *testing.T) { |
| 93 | + ir := shardIR( |
| 94 | + []api.Node{ |
| 95 | + {ID: "fn_a", Labels: []string{"Function"}, Properties: map[string]any{"name": "String", "filePath": "src/types.go"}}, |
| 96 | + {ID: "fn_b", Labels: []string{"Function"}, Properties: map[string]any{"name": "String", "filePath": "src/types.go"}}, |
| 97 | + {ID: "caller1", Labels: []string{"Function"}, Properties: map[string]any{"name": "callA", "filePath": "src/other.go"}}, |
| 98 | + {ID: "caller2", Labels: []string{"Function"}, Properties: map[string]any{"name": "callB", "filePath": "src/other.go"}}, |
| 99 | + }, |
| 100 | + []api.Relationship{ |
| 101 | + {ID: "r1", Type: "calls", StartNode: "caller1", EndNode: "fn_a"}, |
| 102 | + {ID: "r2", Type: "calls", StartNode: "caller2", EndNode: "fn_b"}, |
| 103 | + }, |
| 104 | + ) |
| 105 | + |
| 106 | + c := makeRenderCache(ir) |
| 107 | + |
| 108 | + // Run renderCallsSection multiple times to detect non-determinism |
| 109 | + first := renderCallsSection("src/types.go", c, "//") |
| 110 | + for i := 0; i < 10; i++ { |
| 111 | + out := renderCallsSection("src/types.go", c, "//") |
| 112 | + if out != first { |
| 113 | + t.Errorf("renderCallsSection is non-deterministic (run %d differs from run 0):\nfirst:\n%s\nlater:\n%s", i+1, first, out) |
| 114 | + } |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +// TestRenderCallsSection_ContainsCallerAndCallee verifies basic content correctness. |
| 119 | +func TestRenderCallsSection_ContainsCallerAndCallee(t *testing.T) { |
| 120 | + ir := shardIR( |
| 121 | + []api.Node{ |
| 122 | + {ID: "fn_target", Labels: []string{"Function"}, Properties: map[string]any{"name": "processRequest", "filePath": "src/handler.go"}}, |
| 123 | + {ID: "fn_caller", Labels: []string{"Function"}, Properties: map[string]any{"name": "main", "filePath": "src/main.go"}}, |
| 124 | + {ID: "fn_callee", Labels: []string{"Function"}, Properties: map[string]any{"name": "validate", "filePath": "src/util.go"}}, |
| 125 | + }, |
| 126 | + []api.Relationship{ |
| 127 | + {ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_target"}, |
| 128 | + {ID: "r2", Type: "calls", StartNode: "fn_target", EndNode: "fn_callee"}, |
| 129 | + }, |
| 130 | + ) |
| 131 | + |
| 132 | + c := makeRenderCache(ir) |
| 133 | + out := renderCallsSection("src/handler.go", c, "//") |
| 134 | + |
| 135 | + if out == "" { |
| 136 | + t.Fatal("expected non-empty output for function with caller and callee") |
| 137 | + } |
| 138 | + if !strings.Contains(out, "[calls]") { |
| 139 | + t.Errorf("should contain [calls] header:\n%s", out) |
| 140 | + } |
| 141 | + if !strings.Contains(out, "processRequest ← main") { |
| 142 | + t.Errorf("should show caller relationship:\n%s", out) |
| 143 | + } |
| 144 | + if !strings.Contains(out, "processRequest → validate") { |
| 145 | + t.Errorf("should show callee relationship:\n%s", out) |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +// TestRenderCallsSection_EmptyWhenNoCallRelationships returns empty for a file |
| 150 | +// with functions that have no callers or callees. |
| 151 | +func TestRenderCallsSection_EmptyWhenNoCallRelationships(t *testing.T) { |
| 152 | + ir := shardIR( |
| 153 | + []api.Node{ |
| 154 | + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "isolated", "filePath": "src/a.go"}}, |
| 155 | + }, |
| 156 | + nil, |
| 157 | + ) |
| 158 | + c := makeRenderCache(ir) |
| 159 | + out := renderCallsSection("src/a.go", c, "//") |
| 160 | + if out != "" { |
| 161 | + t.Errorf("expected empty output for function with no call relationships, got:\n%s", out) |
| 162 | + } |
| 163 | +} |
0 commit comments