From 20e785421f9a6f89d099c229ef8f708be899c480 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:10:52 -0400 Subject: [PATCH 1/8] fix: rune-safe byte slicing in token estimator, error snippets, and maskKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore/render.go: countTokens used len(text)/4 (bytes) instead of utf8.RuneCountInString(text)/4 (runes) — inflated estimate for any text containing multi-byte characters (CJK, emoji, accented chars) - api/client.go: HTTP error snippet was truncated at byte offset 300, risking invalid UTF-8 when a multi-byte character straddles that boundary - setup/wizard.go: maskKey sliced the key at byte positions 8 and len-4; same class of bug fixed elsewhere in the codebase Co-Authored-By: Claude Sonnet 4.6 --- internal/api/client.go | 4 ++-- internal/restore/render.go | 3 ++- internal/setup/wizard.go | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index cf2206c..6ebcc7d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -313,8 +313,8 @@ func (c *Client) request(ctx context.Context, method, path, contentType string, return &apiErr } snippet := string(respBody) - if len(snippet) > 300 { - snippet = snippet[:300] + "..." + if runes := []rune(snippet); len(runes) > 300 { + snippet = string(runes[:300]) + "..." } return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet) } diff --git a/internal/restore/render.go b/internal/restore/render.go index 08f0fb1..d274dcf 100644 --- a/internal/restore/render.go +++ b/internal/restore/render.go @@ -7,6 +7,7 @@ import ( gotmpl "text/template" "time" "unicode" + "unicode/utf8" ) const maxCyclesToShow = 10 @@ -288,7 +289,7 @@ func CountTokens(text string) int { inWord = true } } - charEstimate := len(text) / 4 + charEstimate := utf8.RuneCountInString(text) / 4 wordEstimate := words * 100 / 75 if charEstimate > wordEstimate { return charEstimate diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 72bc9f6..400979b 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -194,10 +194,11 @@ func boolPtr(b bool) *bool { return &b } // maskKey returns a display-safe version of the API key. func maskKey(key string) string { - if len(key) <= 12 { - return strings.Repeat("*", len(key)) + runes := []rune(key) + if len(runes) <= 12 { + return strings.Repeat("*", len(runes)) } - return key[:8] + "..." + key[len(key)-4:] + return string(runes[:8]) + "..." + string(runes[len(runes)-4:]) } // findGitRoot detects the git root from the current working directory. From 5927c2835ed69394ad98b371202516b15aa4be9f Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:20:13 -0400 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20dedupSorted=20mutates=20input,=20BFS?= =?UTF-8?q?=20O(n=C3=97m)=20in=20reachableImports,=20token=20estimate=20us?= =?UTF-8?q?es=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - find/handler.go: dedupSorted used ss[:1] as the output head, sharing the backing array with the input slice. Appends to out overwrote the caller's map value. Fixed by making an explicit copy before sorting. Adds TestDedupSorted_DoesNotMutateInput regression test. - focus/handler.go: reachableImports iterated all relationships for every node in the BFS queue (O(depth × queue × rels)). Pre-index imports edges by source node to reduce the BFS to O(rels + visited). - focus/handler.go: estimateTokens used len(s)/4 (bytes) instead of utf8.RuneCountInString(s)/4 (runes), inflating the hint for non-ASCII paths or function names. - Adds TestCountTokens_MultiByteChars to guard the rune-count fix in restore/render.go (landed in previous commit). - Adds wizard_test.go with full coverage for maskKey including a multi-byte rune regression test. Co-Authored-By: Claude Sonnet 4.6 --- internal/find/handler.go | 8 +++-- internal/find/handler_test.go | 38 +++++++++++++++++++++++ internal/focus/handler.go | 24 +++++++++------ internal/restore/restore_test.go | 12 ++++++++ internal/setup/wizard_test.go | 52 ++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 internal/setup/wizard_test.go diff --git a/internal/find/handler.go b/internal/find/handler.go index cddf608..e04d6fc 100644 --- a/internal/find/handler.go +++ b/internal/find/handler.go @@ -141,9 +141,11 @@ func dedupSorted(ss []string) []string { if len(ss) == 0 { return nil } - sort.Strings(ss) - out := ss[:1] - for _, s := range ss[1:] { + cp := make([]string, len(ss)) + copy(cp, ss) + sort.Strings(cp) + out := cp[:1] + for _, s := range cp[1:] { if s != out[len(out)-1] { out = append(out, s) } diff --git a/internal/find/handler_test.go b/internal/find/handler_test.go index fc6aa8e..e5ede68 100644 --- a/internal/find/handler_test.go +++ b/internal/find/handler_test.go @@ -227,6 +227,44 @@ func TestPrintMatches_HumanShowsMatchCount(t *testing.T) { } } +// ── dedupSorted ─────────────────────────────────────────────────────────────── + +func TestDedupSorted_Basic(t *testing.T) { + got := dedupSorted([]string{"c", "a", "b", "a"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestDedupSorted_Empty(t *testing.T) { + if got := dedupSorted(nil); got != nil { + t.Errorf("nil input: want nil, got %v", got) + } + if got := dedupSorted([]string{}); got != nil { + t.Errorf("empty input: want nil, got %v", got) + } +} + +func TestDedupSorted_DoesNotMutateInput(t *testing.T) { + // Prior bug: out := ss[:1] shared the backing array, so appends overwrote + // the original slice. Verify the input is unchanged after dedupSorted. + input := []string{"b", "a", "c", "a"} + snapshot := make([]string, len(input)) + copy(snapshot, input) + dedupSorted(input) + for i, v := range snapshot { + if input[i] != v { + t.Errorf("input mutated at index %d: was %q, now %q", i, v, input[i]) + } + } +} + // ── helpers ─────────────────────────────────────────────────────────────────── func makeGraph() *api.Graph { diff --git a/internal/focus/handler.go b/internal/focus/handler.go index 7926e9d..cfcd064 100644 --- a/internal/focus/handler.go +++ b/internal/focus/handler.go @@ -7,6 +7,7 @@ import ( "os" "sort" "strings" + "unicode/utf8" "github.com/supermodeltools/cli/internal/analyze" "github.com/supermodeltools/cli/internal/api" @@ -171,6 +172,14 @@ func extract(g *api.Graph, target string, depth int, includeTypes bool) *Slice { // reachableImports does a BFS on IMPORTS edges from seed, up to maxDepth hops, // and returns the file/package paths of the imported nodes. func reachableImports(g *api.Graph, seedID string, nodeByID map[string]*api.Node, rels []api.Relationship, maxDepth int) []string { + // Pre-index imports edges by source node to avoid O(queue × rels) inner loop. + importEdges := make(map[string][]string, len(rels)/2) + for _, rel := range rels { + if rel.Type == "imports" || rel.Type == "wildcard_imports" { + importEdges[rel.StartNode] = append(importEdges[rel.StartNode], rel.EndNode) + } + } + visited := map[string]bool{seedID: true} queue := []string{seedID} var imports []string @@ -178,16 +187,13 @@ func reachableImports(g *api.Graph, seedID string, nodeByID map[string]*api.Node for depth := 0; depth < maxDepth && len(queue) > 0; depth++ { next := make([]string, 0) for _, cur := range queue { - for _, rel := range rels { - if rel.Type != "imports" && rel.Type != "wildcard_imports" { - continue - } - if rel.StartNode != cur || visited[rel.EndNode] { + for _, endNode := range importEdges[cur] { + if visited[endNode] { continue } - visited[rel.EndNode] = true - next = append(next, rel.EndNode) - if n := nodeByID[rel.EndNode]; n != nil { + visited[endNode] = true + next = append(next, endNode) + if n := nodeByID[endNode]; n != nil { p := n.Prop("path", "name", "importPath") if p != "" { imports = append(imports, p) @@ -265,7 +271,7 @@ func estimateTokens(sl *Slice) int { for _, c := range sl.CalledBy { s += c.Caller + c.File } - return len(s) / 4 + return utf8.RuneCountInString(s) / 4 } func pathMatches(nodePath, target string) bool { diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 6423c05..28acd0e 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -52,6 +52,18 @@ func TestCountTokens_RealText(t *testing.T) { } } +func TestCountTokens_MultiByteChars(t *testing.T) { + // Prior bug: used len(text)/4 (bytes) not RuneCountInString/4. + // Each CJK character is 3 bytes; 100 of them = 300 bytes but only 100 runes. + // charEstimate must be 100/4 = 25, not 300/4 = 75. + cjk := strings.Repeat("中", 100) // 100 runes, 300 bytes + got := CountTokens(cjk) + // charEstimate = 25, wordEstimate = 1*100/75 = 1 → 25 + if got != 25 { + t.Errorf("100 CJK chars: want 25 tokens, got %d (byte-based would give 75)", got) + } +} + // ── isHorizontalRule ───────────────────────────────────────────────────────── func TestIsHorizontalRule(t *testing.T) { diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go new file mode 100644 index 0000000..f59b73f --- /dev/null +++ b/internal/setup/wizard_test.go @@ -0,0 +1,52 @@ +package setup + +import ( + "strings" + "testing" +) + +// ── maskKey ─────────────────────────────────────────────────────────────────── + +func TestMaskKey_Short(t *testing.T) { + // Keys ≤12 chars are fully masked with '*'. + for _, key := range []string{"", "abc", "123456789012"} { + got := maskKey(key) + if got != strings.Repeat("*", len([]rune(key))) { + t.Errorf("maskKey(%q) = %q, want all stars", key, got) + } + } +} + +func TestMaskKey_Long(t *testing.T) { + // Keys >12 chars: first 8 chars, "...", last 4 chars visible. + key := "sk-ant-abcdefghijklmnop" + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(%q) = %q, want %q", key, got, want) + } +} + +func TestMaskKey_ExactlyThirteen(t *testing.T) { + // 13 chars: just over the threshold. + key := "abcdefghijklm" // 13 chars + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(%q) = %q, want %q", key, got, want) + } +} + +func TestMaskKey_MultiByteRunes(t *testing.T) { + // Prior bug: sliced at byte positions, not rune boundaries. + // Each emoji is 4 bytes; 20 of them = 80 bytes but 20 runes. + key := strings.Repeat("😀", 20) // 20 runes, 80 bytes + got := maskKey(key) + runes := []rune(key) + want := string(runes[:8]) + "..." + string(runes[len(runes)-4:]) + if got != want { + t.Errorf("maskKey(20×emoji): got %q, want %q", got, want) + } +} From 9cdfd111133a33ae120eaafe1717a52a48bba9ad Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:21:11 -0400 Subject: [PATCH 3/8] fix(restore): propagate src.Close() error in copyFileIntoZip If io.Copy succeeded but src.Close() failed, the close error was silently discarded. Preserve the intent of not using defer (to avoid accumulating open handles across Walk iterations) while returning the close error when no copy error occurred. Co-Authored-By: Claude Sonnet 4.6 --- cmd/restore.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/restore.go b/cmd/restore.go index 3d6e9aa..9fb5ca3 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -245,7 +245,9 @@ func copyFileIntoZip(path string, w io.Writer) error { return err } _, err = io.Copy(w, src) - src.Close() + if closeErr := src.Close(); err == nil { + err = closeErr + } return err } From 4b36b657138d58681981613ba2ef460334525b57 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:36:42 -0400 Subject: [PATCH 4/8] test: expand coverage across api, blastradius, cache, mcp, render, and shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api: add GraphFromShardIR tests (Nodes, Rels, RepoID, NodeByID, NodesByLabel, empty) - blastradius: new zip_test.go covering isGitRepo, isWorktreeClean, walkZip (hidden files, skip dirs, createZip round-trip) - cache: add PutJSON/GetJSON round-trip, miss, and overwrite tests - mcp: add boolArg and intArg tests - render/funcs: add 23 tests for totalTime, formatDuration, seq, dict, reverseStrings, minInt/maxInt, length, entity accessors (fieldAccess, sectionAccess, getStringSlice, hasField, getInt, getFloat), jsonMarshal/toJSON, defaultVal/ternary/hasKey, parseQuantity, parseUnit, parseIngredient*, fractionDisplay, scaleQty - shards: new graph_test.go (25 tests for Cache.Build, SourceFiles, FuncName, TransitiveDependents, computeStats, isShardPath, firstString, intProp) - shards: extend render_test.go (CommentPrefix, ShardFilename, Header, sortedUnique, sortedBoolKeys, formatLoc, renderDepsSection, renderImpactSection, RenderGraph, WriteShard, updateGitignore) Coverage deltas: render 11%→45%, blastradius 32%→60%, cache 61%→78% Co-Authored-By: Claude Sonnet 4.6 --- internal/api/types_test.go | 102 +++++ internal/archdocs/pssg/render/funcs_test.go | 376 ++++++++++++++++++ internal/blastradius/zip_test.go | 157 ++++++++ internal/cache/cache_test.go | 61 +++ internal/mcp/server_test.go | 34 ++ internal/shards/graph_test.go | 407 ++++++++++++++++++++ internal/shards/render_test.go | 318 +++++++++++++++ 7 files changed, 1455 insertions(+) create mode 100644 internal/blastradius/zip_test.go create mode 100644 internal/shards/graph_test.go diff --git a/internal/api/types_test.go b/internal/api/types_test.go index 57fbda2..7411eba 100644 --- a/internal/api/types_test.go +++ b/internal/api/types_test.go @@ -232,6 +232,108 @@ func TestError_Error_WithoutCode(t *testing.T) { } } +// ── GraphFromShardIR ────────────────────────────────────────────────────────── + +func TestGraphFromShardIR_NodesAndRels(t *testing.T) { + ir := &ShardIR{ + Repo: "myorg/myrepo", + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "n1", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "n2", Labels: []string{"Function"}, Properties: map[string]any{"name": "doThing"}}, + }, + Relationships: []Relationship{ + {ID: "r1", Type: "defines_function", StartNode: "n1", EndNode: "n2"}, + }, + }, + } + g := GraphFromShardIR(ir) + + if len(g.Nodes) != 2 { + t.Errorf("nodes: got %d, want 2", len(g.Nodes)) + } + if len(g.Relationships) != 1 { + t.Errorf("relationships: got %d, want 1", len(g.Relationships)) + } + if g.Nodes[0].ID != "n1" { + t.Errorf("first node ID: got %q", g.Nodes[0].ID) + } +} + +func TestGraphFromShardIR_RepoID(t *testing.T) { + ir := &ShardIR{Repo: "acme/backend"} + g := GraphFromShardIR(ir) + if got := g.RepoID(); got != "acme/backend" { + t.Errorf("RepoID: got %q, want 'acme/backend'", got) + } +} + +func TestGraphFromShardIR_RelsViaRels(t *testing.T) { + // Rels() should return the Relationships slice (not Edges) + ir := &ShardIR{ + Graph: ShardGraph{ + Relationships: []Relationship{ + {ID: "r1", Type: "imports"}, + {ID: "r2", Type: "calls"}, + }, + }, + } + g := GraphFromShardIR(ir) + rels := g.Rels() + if len(rels) != 2 { + t.Errorf("Rels(): got %d, want 2", len(rels)) + } +} + +func TestGraphFromShardIR_Empty(t *testing.T) { + ir := &ShardIR{} + g := GraphFromShardIR(ir) + if g == nil { + t.Fatal("GraphFromShardIR returned nil") + } + if len(g.Nodes) != 0 { + t.Errorf("empty IR: expected 0 nodes, got %d", len(g.Nodes)) + } + if g.RepoID() != "" { + t.Errorf("empty IR: expected empty repoId, got %q", g.RepoID()) + } +} + +func TestGraphFromShardIR_NodeByID(t *testing.T) { + ir := &ShardIR{ + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "myFunc"}}, + }, + }, + } + g := GraphFromShardIR(ir) + n, ok := g.NodeByID("fn1") + if !ok { + t.Fatal("NodeByID('fn1') returned false") + } + if n.Prop("name") != "myFunc" { + t.Errorf("name prop: got %q", n.Prop("name")) + } +} + +func TestGraphFromShardIR_NodesByLabel(t *testing.T) { + ir := &ShardIR{ + Graph: ShardGraph{ + Nodes: []Node{ + {ID: "f1", Labels: []string{"File"}}, + {ID: "fn1", Labels: []string{"Function"}}, + {ID: "f2", Labels: []string{"File"}}, + }, + }, + } + g := GraphFromShardIR(ir) + files := g.NodesByLabel("File") + if len(files) != 2 { + t.Errorf("NodesByLabel('File'): got %d, want 2", len(files)) + } +} + func containsStr(s, sub string) bool { return len(s) >= len(sub) && (s == sub || func() bool { diff --git a/internal/archdocs/pssg/render/funcs_test.go b/internal/archdocs/pssg/render/funcs_test.go index 4e7507e..af37132 100644 --- a/internal/archdocs/pssg/render/funcs_test.go +++ b/internal/archdocs/pssg/render/funcs_test.go @@ -1,6 +1,8 @@ package render import ( + "math" + "strings" "testing" "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" @@ -110,3 +112,377 @@ func TestSortStrings(t *testing.T) { t.Errorf("sortStrings modified original slice") } } + +// ── totalTime ───────────────────────────────────────────────────────────────── + +func TestTotalTime(t *testing.T) { + cases := []struct { + d1, d2, want string + }{ + {"PT30M", "PT30M", "PT1H"}, + {"PT1H", "PT30M", "PT1H30M"}, + {"PT45M", "PT20M", "PT1H5M"}, + {"PT10M", "PT5M", "PT15M"}, + {"PT2H", "PT0M", "PT2H"}, + } + for _, c := range cases { + got := totalTime(c.d1, c.d2) + if got != c.want { + t.Errorf("totalTime(%q, %q) = %q, want %q", c.d1, c.d2, got, c.want) + } + } +} + +// ── formatDuration ──────────────────────────────────────────────────────────── + +func TestFormatDuration(t *testing.T) { + cases := []struct { + input, want string + }{ + {"PT30M", "30 min"}, + {"PT1H", "1 hr"}, + {"PT2H", "2 hrs"}, + {"PT1H30M", "1 hr 30 min"}, + {"PT90M", "1 hr 30 min"}, + {"PT0S", "PT0S"}, // 0 minutes → passthrough + {"invalid", "invalid"}, + } + for _, c := range cases { + got := formatDuration(c.input) + if got != c.want { + t.Errorf("formatDuration(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// ── seq ─────────────────────────────────────────────────────────────────────── + +func TestSeq(t *testing.T) { + got := seq(5) + if len(got) != 5 { + t.Fatalf("seq(5): len=%d", len(got)) + } + for i, v := range got { + if v != i+1 { + t.Errorf("seq(5)[%d] = %d, want %d", i, v, i+1) + } + } + if len(seq(0)) != 0 { + t.Error("seq(0) should return empty slice") + } +} + +// ── dict ────────────────────────────────────────────────────────────────────── + +func TestDict(t *testing.T) { + m := dict("key1", "val1", "key2", 42) + if m["key1"] != "val1" { + t.Errorf("dict: key1=%v, want 'val1'", m["key1"]) + } + if m["key2"] != 42 { + t.Errorf("dict: key2=%v, want 42", m["key2"]) + } + if len(dict()) != 0 { + t.Error("dict() should return empty map") + } + // Odd number of args: last key gets no value, should not panic + m2 := dict("orphan") + if len(m2) != 0 { + t.Errorf("dict with odd args: expected no entries, got %v", m2) + } +} + +// ── reverseStrings ──────────────────────────────────────────────────────────── + +func TestReverseStrings(t *testing.T) { + got := reverseStrings([]string{"a", "b", "c"}) + if got[0] != "c" || got[1] != "b" || got[2] != "a" { + t.Errorf("reverseStrings: got %v", got) + } + if len(reverseStrings(nil)) != 0 { + t.Error("reverseStrings(nil) should return empty") + } +} + +// ── minInt / maxInt ─────────────────────────────────────────────────────────── + +func TestMinInt(t *testing.T) { + if minInt(3, 5) != 3 { + t.Error("minInt(3,5) should be 3") + } + if minInt(7, 2) != 2 { + t.Error("minInt(7,2) should be 2") + } + if minInt(4, 4) != 4 { + t.Error("minInt(4,4) should be 4") + } +} + +func TestMaxInt(t *testing.T) { + if maxInt(3, 5) != 5 { + t.Error("maxInt(3,5) should be 5") + } + if maxInt(7, 2) != 7 { + t.Error("maxInt(7,2) should be 7") + } +} + +// ── length ──────────────────────────────────────────────────────────────────── + +func TestLength(t *testing.T) { + if length([]string{"a", "b"}) != 2 { + t.Error("[]string length") + } + if length(map[string]int{"x": 1}) != 1 { + t.Error("map length") + } + if length("hello") != 5 { + t.Error("string length") + } + if length(nil) != 0 { + t.Error("nil length should be 0") + } + if length(42) != 0 { + t.Error("int length should be 0") + } +} + +// ── Entity accessor functions ───────────────────────────────────────────────── + +func newTestEntity(fields map[string]interface{}) *entity.Entity { + return &entity.Entity{ + Fields: fields, + Sections: map[string]interface{}{}, + } +} + +func TestFieldAccess_NilEntity(t *testing.T) { + if fieldAccess(nil, "key") != nil { + t.Error("fieldAccess(nil) should return nil") + } +} + +func TestFieldAccess_Present(t *testing.T) { + e := newTestEntity(map[string]interface{}{"title": "My Recipe"}) + if got := fieldAccess(e, "title"); got != "My Recipe" { + t.Errorf("fieldAccess: got %v", got) + } +} + +func TestSectionAccess_NilEntity(t *testing.T) { + if sectionAccess(nil, "intro") != nil { + t.Error("sectionAccess(nil) should return nil") + } +} + +func TestSectionAccess_Present(t *testing.T) { + e := &entity.Entity{ + Fields: map[string]interface{}{}, + Sections: map[string]interface{}{"ingredients": []string{"flour", "eggs"}}, + } + v := sectionAccess(e, "ingredients") + ss, ok := v.([]string) + if !ok || len(ss) != 2 { + t.Errorf("sectionAccess: got %v", v) + } +} + +func TestGetStringSlice_NilEntity(t *testing.T) { + if getStringSlice(nil, "k") != nil { + t.Error("getStringSlice(nil) should return nil") + } +} + +func TestGetStringSlice_Present(t *testing.T) { + e := newTestEntity(map[string]interface{}{"tags": []string{"vegan", "quick"}}) + got := getStringSlice(e, "tags") + if len(got) != 2 || got[0] != "vegan" { + t.Errorf("getStringSlice: got %v", got) + } +} + +func TestHasField(t *testing.T) { + e := newTestEntity(map[string]interface{}{"title": "X"}) + if !hasField(e, "title") { + t.Error("hasField: should find 'title'") + } + if hasField(e, "missing") { + t.Error("hasField: should not find 'missing'") + } + if hasField(nil, "title") { + t.Error("hasField(nil): should return false") + } +} + +func TestGetInt(t *testing.T) { + e := newTestEntity(map[string]interface{}{"servings": 4}) + if got := getInt(e, "servings"); got != 4 { + t.Errorf("getInt: got %d", got) + } + if getInt(nil, "k") != 0 { + t.Error("getInt(nil) should return 0") + } +} + +func TestGetFloat(t *testing.T) { + e := newTestEntity(map[string]interface{}{"rating": float64(4.5)}) + if got := getFloat(e, "rating"); got != 4.5 { + t.Errorf("getFloat: got %f", got) + } + if getFloat(nil, "k") != 0 { + t.Error("getFloat(nil) should return 0") + } +} + +// ── jsonMarshal / toJSON ────────────────────────────────────────────────────── + +func TestJsonMarshal(t *testing.T) { + got := string(jsonMarshal(map[string]int{"x": 1})) + if !strings.Contains(got, `"x":1`) { + t.Errorf("jsonMarshal: got %q", got) + } +} + +func TestToJSON(t *testing.T) { + got := toJSON([]string{"a", "b"}) + if got != `["a","b"]` { + t.Errorf("toJSON: got %q", got) + } +} + +// ── defaultVal / ternary / hasKey ───────────────────────────────────────────── + +func TestDefaultVal(t *testing.T) { + if defaultVal("fallback", nil) != "fallback" { + t.Error("nil should use default") + } + if defaultVal("fallback", "") != "fallback" { + t.Error("empty string should use default") + } + if defaultVal("fallback", "value") != "value" { + t.Error("non-empty should not use default") + } +} + +func TestTernary(t *testing.T) { + if ternary(true, "yes", "no") != "yes" { + t.Error("ternary(true): want 'yes'") + } + if ternary(false, "yes", "no") != "no" { + t.Error("ternary(false): want 'no'") + } +} + +func TestHasKey(t *testing.T) { + m := map[string]interface{}{"a": 1} + if !hasKey(m, "a") { + t.Error("hasKey: should find 'a'") + } + if hasKey(m, "b") { + t.Error("hasKey: should not find 'b'") + } +} + +// ── parseQuantity ───────────────────────────────────────────────────────────── + +func TestParseQuantity(t *testing.T) { + cases := []struct { + input string + qty float64 + rest string + }{ + {"2 cups flour", 2, "cups flour"}, + {"1 1/2 cups sugar", 1.5, "cups sugar"}, + {"1/2 tsp salt", 0.5, "tsp salt"}, + {"3 eggs", 3, "eggs"}, + {"", 0, ""}, + {"½ cup milk", 0.5, "cup milk"}, + } + for _, c := range cases { + qty, rest := parseQuantity(c.input) + if math.Abs(qty-c.qty) > 0.01 { + t.Errorf("parseQuantity(%q).qty = %f, want %f", c.input, qty, c.qty) + } + if rest != c.rest { + t.Errorf("parseQuantity(%q).rest = %q, want %q", c.input, rest, c.rest) + } + } +} + +// ── parseUnit ───────────────────────────────────────────────────────────────── + +func TestParseUnit(t *testing.T) { + cases := []struct { + input, unit, rest string + }{ + {"cups flour", "cup", "flour"}, + {"tsp salt", "teaspoon", "salt"}, + {"tablespoon oil", "tablespoon", "oil"}, + {"g butter", "gram", "butter"}, + {"eggs", "", "eggs"}, // no unit + {"", "", ""}, + } + for _, c := range cases { + unit, rest := parseUnit(c.input) + if unit != c.unit { + t.Errorf("parseUnit(%q).unit = %q, want %q", c.input, unit, c.unit) + } + if rest != c.rest { + t.Errorf("parseUnit(%q).rest = %q, want %q", c.input, rest, c.rest) + } + } +} + +// ── parseIngredient* wrappers ───────────────────────────────────────────────── + +func TestParseIngredientFunctions(t *testing.T) { + line := "2 cups flour" + if got := parseIngredientQty(line); math.Abs(got-2) > 0.01 { + t.Errorf("parseIngredientQty(%q) = %f, want 2", line, got) + } + if got := parseIngredientUnit(line); got != "cup" { + t.Errorf("parseIngredientUnit(%q) = %q, want 'cup'", line, got) + } + if got := parseIngredientDesc(line); got != "flour" { + t.Errorf("parseIngredientDesc(%q) = %q, want 'flour'", line, got) + } +} + +// ── fractionDisplay ─────────────────────────────────────────────────────────── + +func TestFractionDisplay(t *testing.T) { + cases := []struct { + input float64 + want string + }{ + {0, "0"}, + {1, "1"}, + {2, "2"}, + {0.5, "½"}, // 0.5 is exactly ½ + {0.75, "¾"}, // 0.75 is exactly ¾ + {1.5, "1 ½"}, // whole + fraction + {0.125, "⅛"}, // exactly ⅛ + {0.875, "⅞"}, // exactly ⅞ + } + for _, c := range cases { + got := fractionDisplay(c.input) + if got != c.want { + t.Errorf("fractionDisplay(%v) = %q, want %q", c.input, got, c.want) + } + } +} + +// ── scaleQty ────────────────────────────────────────────────────────────────── + +func TestScaleQty(t *testing.T) { + // 1 cup base for 2 servings → scaled to 4 servings = 2 cups + got := scaleQty(1.0, 2, 4) + if got != "2" { + t.Errorf("scaleQty(1.0, 2, 4) = %q, want '2'", got) + } + // zero base servings → returns fractionDisplay of base qty + got = scaleQty(0.5, 0, 4) + if got != "½" { + t.Errorf("scaleQty(0.5, 0, 4) = %q, want '½'", got) + } +} diff --git a/internal/blastradius/zip_test.go b/internal/blastradius/zip_test.go new file mode 100644 index 0000000..568e058 --- /dev/null +++ b/internal/blastradius/zip_test.go @@ -0,0 +1,157 @@ +package blastradius + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" +) + +// ── isGitRepo ───────────────────────────────────────────────────────────────── + +func TestIsGitRepo_NonGitDir(t *testing.T) { + if isGitRepo(t.TempDir()) { + t.Error("empty temp dir should not be a git repo") + } +} + +// ── isWorktreeClean ─────────────────────────────────────────────────────────── + +func TestIsWorktreeClean_NonGitDir(t *testing.T) { + // git status on a non-repo exits non-zero → returns false + if isWorktreeClean(t.TempDir()) { + t.Error("non-git dir should not be considered clean") + } +} + +// ── walkZip ─────────────────────────────────────────────────────────────────── + +func TestWalkZip_IncludesSourceFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatalf("walkZip: %v", err) + } + entries := readBlastZipEntries(t, dest) + if !entries["main.go"] { + t.Error("zip should contain main.go") + } +} + +func TestWalkZip_SkipsHiddenFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("SECRET=x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "app.ts"), []byte("export {}"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatal(err) + } + entries := readBlastZipEntries(t, dest) + if entries[".env"] { + t.Error("zip should not contain .env") + } + if !entries["app.ts"] { + t.Error("zip should contain app.ts") + } +} + +func TestWalkZip_SkipsNodeModules(t *testing.T) { + src := t.TempDir() + nmDir := filepath.Join(src, "node_modules") + if err := os.Mkdir(nmDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nmDir, "pkg.js"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "index.ts"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatal(err) + } + entries := readBlastZipEntries(t, dest) + for name := range entries { + if strings.HasPrefix(name, "node_modules/") || name == "node_modules" { + t.Errorf("should not contain node_modules entry: %s", name) + } + } + if !entries["index.ts"] { + t.Error("zip should contain index.ts") + } +} + +func TestWalkZip_SkipsOtherSkipDirs(t *testing.T) { + for _, dir := range []string{"dist", "build", "vendor", ".git"} { + src := t.TempDir() + skipDir := filepath.Join(src, dir) + if err := os.Mkdir(skipDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skipDir, "file.js"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "out.zip") + if err := walkZip(src, dest); err != nil { + t.Fatalf("walkZip with %s: %v", dir, err) + } + entries := readBlastZipEntries(t, dest) + for name := range entries { + if strings.HasPrefix(name, dir+"/") { + t.Errorf("should not contain %s/ entry: %s", dir, name) + } + } + } +} + +// ── createZip ───────────────────────────────────────────────────────────────── + +func TestCreateZip_NonGitDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + path, err := createZip(dir) + if err != nil { + t.Fatalf("createZip: %v", err) + } + defer os.Remove(path) + if _, err := os.Stat(path); err != nil { + t.Errorf("zip file not created: %v", err) + } + // Verify it's a valid zip + entries := readBlastZipEntries(t, path) + if !entries["main.go"] { + t.Error("created zip should contain main.go") + } +} + +func readBlastZipEntries(t *testing.T, path string) map[string]bool { + t.Helper() + r, err := zip.OpenReader(path) + if err != nil { + t.Fatalf("open zip %s: %v", path, err) + } + defer r.Close() + m := make(map[string]bool, len(r.File)) + for _, f := range r.File { + m[f.Name] = true + } + return m +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 0650f7a..4ed4705 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -193,6 +193,67 @@ func TestPut_OverwritesExisting(t *testing.T) { } } +// ── PutJSON / GetJSON ───────────────────────────────────────────────────────── + +func TestPutGetJSON_RoundTrip(t *testing.T) { + withTempCacheDir(t) + + type payload struct { + Name string `json:"name"` + Count int `json:"count"` + } + v := payload{Name: "deadcode", Count: 42} + + if err := PutJSON("jsonhash1", v); err != nil { + t.Fatalf("PutJSON: %v", err) + } + + var got payload + hit, err := GetJSON("jsonhash1", &got) + if err != nil { + t.Fatalf("GetJSON: %v", err) + } + if !hit { + t.Fatal("GetJSON: expected cache hit") + } + if got.Name != "deadcode" || got.Count != 42 { + t.Errorf("GetJSON: got %+v, want {deadcode 42}", got) + } +} + +func TestGetJSON_Miss(t *testing.T) { + withTempCacheDir(t) + + var v any + hit, err := GetJSON("nonexistent", &v) + if err != nil { + t.Fatalf("GetJSON miss: want nil error, got %v", err) + } + if hit { + t.Error("GetJSON miss: want hit=false") + } +} + +func TestPutGetJSON_Overwrite(t *testing.T) { + withTempCacheDir(t) + + if err := PutJSON("overwrite-key", map[string]string{"v": "1"}); err != nil { + t.Fatal(err) + } + if err := PutJSON("overwrite-key", map[string]string{"v": "2"}); err != nil { + t.Fatal(err) + } + + var got map[string]string + hit, err := GetJSON("overwrite-key", &got) + if err != nil || !hit { + t.Fatalf("GetJSON: hit=%v err=%v", hit, err) + } + if got["v"] != "2" { + t.Errorf("expected overwritten value '2', got %q", got["v"]) + } +} + // ── helpers ─────────────────────────────────────────────────────────────────── func writeTempFile(t *testing.T, content []byte) string { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 2ad108c..70a6df2 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -204,6 +204,40 @@ func makeTestGraph() *api.Graph { } } +// ── boolArg / intArg ────────────────────────────────────────────────────────── + +func TestBoolArg(t *testing.T) { + args := map[string]any{"flag": true, "off": false, "num": 42} + if !boolArg(args, "flag") { + t.Error("boolArg(flag=true) should return true") + } + if boolArg(args, "off") { + t.Error("boolArg(off=false) should return false") + } + if boolArg(args, "num") { + t.Error("boolArg(num=42) should return false (wrong type)") + } + if boolArg(args, "absent") { + t.Error("boolArg(absent) should return false") + } +} + +func TestIntArg(t *testing.T) { + args := map[string]any{"count": float64(5), "zero": float64(0), "str": "hello"} + if got := intArg(args, "count"); got != 5 { + t.Errorf("intArg(count=5.0) = %d, want 5", got) + } + if got := intArg(args, "zero"); got != 0 { + t.Errorf("intArg(zero=0.0) = %d, want 0", got) + } + if got := intArg(args, "str"); got != 0 { + t.Errorf("intArg(str='hello') = %d, want 0 (wrong type)", got) + } + if got := intArg(args, "absent"); got != 0 { + t.Errorf("intArg(absent) = %d, want 0", got) + } +} + func TestFormatImpact_NoEntryPoints(t *testing.T) { result := &api.ImpactResult{ Metadata: api.ImpactMetadata{TargetsAnalyzed: 1, TotalFiles: 50, TotalFunctions: 200}, diff --git a/internal/shards/graph_test.go b/internal/shards/graph_test.go new file mode 100644 index 0000000..a8f9828 --- /dev/null +++ b/internal/shards/graph_test.go @@ -0,0 +1,407 @@ +package shards + +import ( + "testing" + + "github.com/supermodeltools/cli/internal/api" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func fileNode(id, path string) api.Node { + return api.Node{ID: id, Labels: []string{"File"}, Properties: map[string]any{"filePath": path}} +} + +func fnNode(id, name, filePath string) api.Node { + return api.Node{ID: id, Labels: []string{"Function"}, Properties: map[string]any{"name": name, "filePath": filePath}} +} + +func fnNodeWithLine(id, name, filePath string, line int) api.Node { + return api.Node{ID: id, Labels: []string{"Function"}, Properties: map[string]any{"name": name, "filePath": filePath, "startLine": float64(line)}} +} + +func rel(id, typ, start, end string) api.Relationship { + return api.Relationship{ID: id, Type: typ, StartNode: start, EndNode: end} +} + +func buildCache(nodes []api.Node, rels []api.Relationship) *Cache { + ir := &api.ShardIR{Graph: api.ShardGraph{Nodes: nodes, Relationships: rels}} + c := NewCache() + c.Build(ir) + return c +} + +// ── isShardPath ─────────────────────────────────────────────────────────────── + +func TestIsShardPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"src/handler.graph.go", true}, + {"src/handler.graph.ts", true}, + {"lib/foo.graph.js", true}, + {"src/handler.go", false}, + {"src/handler.ts", false}, + {"graph.go", false}, // no double extension + {"src/a.b.graph.go", true}, // any double extension with .graph + {"src/file.graph", false}, // .graph alone is not a source ext + } + for _, tc := range cases { + if got := isShardPath(tc.path); got != tc.want { + t.Errorf("isShardPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +// ── firstString ─────────────────────────────────────────────────────────────── + +func TestFirstString(t *testing.T) { + props := map[string]any{"filePath": "src/a.go", "name": "myFile", "empty": ""} + + if got := firstString(props, "filePath", "fallback"); got != "src/a.go" { + t.Errorf("got %q, want src/a.go", got) + } + if got := firstString(props, "missing", "name", "fallback"); got != "myFile" { + t.Errorf("got %q, want myFile", got) + } + // empty string skipped + if got := firstString(props, "empty", "name", "fallback"); got != "myFile" { + t.Errorf("empty string should be skipped: got %q", got) + } + // literal fallback when no key matches + if got := firstString(props, "missing", "fallback"); got != "fallback" { + t.Errorf("got %q, want literal fallback", got) + } +} + +// ── intProp ─────────────────────────────────────────────────────────────────── + +func TestIntProp(t *testing.T) { + n := api.Node{Properties: map[string]any{ + "line": float64(42), + "count": int(7), + "text": "hello", + "missing": nil, + }} + if got := intProp(n, "line"); got != 42 { + t.Errorf("float64 prop: got %d, want 42", got) + } + if got := intProp(n, "count"); got != 7 { + t.Errorf("int prop: got %d, want 7", got) + } + if got := intProp(n, "text"); got != 0 { + t.Errorf("string prop should return 0: got %d", got) + } + if got := intProp(n, "absent"); got != 0 { + t.Errorf("missing prop should return 0: got %d", got) + } +} + +// ── fnFile / fnLine ─────────────────────────────────────────────────────────── + +func TestFnFileAndLine_Nil(t *testing.T) { + if got := fnFile(nil); got != "" { + t.Errorf("fnFile(nil): got %q, want empty", got) + } + if got := fnLine(nil); got != 0 { + t.Errorf("fnLine(nil): got %d, want 0", got) + } +} + +func TestFnFileAndLine_NonNil(t *testing.T) { + fi := &FuncInfo{File: "src/a.go", Line: 10} + if got := fnFile(fi); got != "src/a.go" { + t.Errorf("got %q", got) + } + if got := fnLine(fi); got != 10 { + t.Errorf("got %d", got) + } +} + +// ── Cache.Build ─────────────────────────────────────────────────────────────── + +func TestBuild_IndexesFunctions(t *testing.T) { + c := buildCache( + []api.Node{fnNodeWithLine("fn1", "handleReq", "src/handler.go", 15)}, + nil, + ) + fn, ok := c.FnByID["fn1"] + if !ok { + t.Fatal("fn1 not indexed") + } + if fn.Name != "handleReq" { + t.Errorf("name: got %q", fn.Name) + } + if fn.File != "src/handler.go" { + t.Errorf("file: got %q", fn.File) + } + if fn.Line != 15 { + t.Errorf("line: got %d", fn.Line) + } +} + +func TestBuild_FuncNameFromID(t *testing.T) { + // When "name" prop is absent, name extracted from ID like "fn:src/foo.ts:bar" + n := api.Node{ID: "fn:src/foo.ts:bar", Labels: []string{"Function"}, Properties: map[string]any{"filePath": "src/foo.ts"}} + c := buildCache([]api.Node{n}, nil) + fn, ok := c.FnByID["fn:src/foo.ts:bar"] + if !ok { + t.Fatal("function not indexed") + } + if fn.Name != "bar" { + t.Errorf("expected name 'bar', got %q", fn.Name) + } +} + +func TestBuild_IndexesCallEdges(t *testing.T) { + c := buildCache( + []api.Node{ + fnNode("caller", "main", "src/main.go"), + fnNode("callee", "handle", "src/handler.go"), + }, + []api.Relationship{rel("r1", "calls", "caller", "callee")}, + ) + callers := c.Callers["callee"] + if len(callers) != 1 || callers[0].FuncID != "caller" { + t.Errorf("callers of callee: got %+v", callers) + } + callees := c.Callees["caller"] + if len(callees) != 1 || callees[0].FuncID != "callee" { + t.Errorf("callees of caller: got %+v", callees) + } +} + +func TestBuild_IndexesImportEdges(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.go"), + }, + []api.Relationship{rel("r1", "imports", "f1", "f2")}, + ) + if len(c.Imports["src/a.go"]) != 1 || c.Imports["src/a.go"][0] != "src/b.go" { + t.Errorf("imports: got %v", c.Imports["src/a.go"]) + } + if len(c.Importers["src/b.go"]) != 1 || c.Importers["src/b.go"][0] != "src/a.go" { + t.Errorf("importers: got %v", c.Importers["src/b.go"]) + } +} + +func TestBuild_SkipsExternalImports(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + {ID: "ext1", Labels: []string{"ExternalDependency"}, Properties: map[string]any{"name": "fmt"}}, + }, + []api.Relationship{rel("r1", "imports", "f1", "ext1")}, + ) + if len(c.Imports["src/a.go"]) != 0 { + t.Errorf("external imports should be skipped, got %v", c.Imports["src/a.go"]) + } +} + +func TestBuild_DefinesFunctionSetsFile(t *testing.T) { + // Function node has no filePath but is linked via defines_function + fn := api.Node{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doStuff"}} + c := buildCache( + []api.Node{fileNode("file1", "src/util.go"), fn}, + []api.Relationship{rel("r1", "defines_function", "file1", "fn1")}, + ) + if c.FnByID["fn1"].File != "src/util.go" { + t.Errorf("defines_function should set fn.File; got %q", c.FnByID["fn1"].File) + } +} + +func TestBuild_DomainAssignmentFromKeyFiles(t *testing.T) { + ir := &api.ShardIR{ + Graph: api.ShardGraph{Nodes: []api.Node{fileNode("f1", "src/auth/login.go")}}, + Domains: []api.ShardDomain{{Name: "auth", KeyFiles: []string{"src/auth/login.go"}}}, + } + c := NewCache() + c.Build(ir) + if c.FileDomain["src/auth/login.go"] != "auth" { + t.Errorf("domain assignment: got %q", c.FileDomain["src/auth/login.go"]) + } +} + +// ── SourceFiles ─────────────────────────────────────────────────────────────── + +func TestSourceFiles_ReturnsSourceExts(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.ts"), + fileNode("f3", "src/README.md"), // not a source ext + }, + nil, + ) + files := c.SourceFiles() + want := map[string]bool{"src/a.go": true, "src/b.ts": true} + if len(files) != 2 { + t.Errorf("want 2 source files, got %d: %v", len(files), files) + } + for _, f := range files { + if !want[f] { + t.Errorf("unexpected file %q", f) + } + } +} + +func TestSourceFiles_ExcludesShards(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/handler.go"), + fileNode("f2", "src/handler.graph.go"), + }, + nil, + ) + files := c.SourceFiles() + for _, f := range files { + if isShardPath(f) { + t.Errorf("shard path should be excluded: %q", f) + } + } + if len(files) != 1 || files[0] != "src/handler.go" { + t.Errorf("got %v", files) + } +} + +func TestSourceFiles_IncludesFromImports(t *testing.T) { + c := buildCache( + []api.Node{ + fileNode("f1", "src/a.go"), + fileNode("f2", "src/b.go"), + }, + []api.Relationship{rel("r1", "imports", "f1", "f2")}, + ) + files := c.SourceFiles() + seen := map[string]bool{} + for _, f := range files { + seen[f] = true + } + if !seen["src/a.go"] || !seen["src/b.go"] { + t.Errorf("expected both files, got %v", files) + } +} + +// ── FuncName ────────────────────────────────────────────────────────────────── + +func TestFuncName_Known(t *testing.T) { + c := buildCache([]api.Node{fnNode("fn1", "processRequest", "src/a.go")}, nil) + if got := c.FuncName("fn1"); got != "processRequest" { + t.Errorf("got %q", got) + } +} + +func TestFuncName_Unknown_ExtractsFromID(t *testing.T) { + c := NewCache() + if got := c.FuncName("pkg:file:methodName"); got != "methodName" { + t.Errorf("got %q, want methodName", got) + } +} + +// ── TransitiveDependents ────────────────────────────────────────────────────── + +func TestTransitiveDependents_Direct(t *testing.T) { + // a imports b: b has one direct dependent + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go")}, + []api.Relationship{rel("r1", "imports", "fa", "fb")}, + ) + deps := c.TransitiveDependents("b.go") + if len(deps) != 1 || !deps["a.go"] { + t.Errorf("expected {a.go}, got %v", deps) + } +} + +func TestTransitiveDependents_Transitive(t *testing.T) { + // a→b→c: c has two dependents (a, b) + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go"), fileNode("fc", "c.go")}, + []api.Relationship{ + rel("r1", "imports", "fa", "fb"), + rel("r2", "imports", "fb", "fc"), + }, + ) + deps := c.TransitiveDependents("c.go") + if !deps["a.go"] || !deps["b.go"] { + t.Errorf("expected a.go and b.go, got %v", deps) + } + if deps["c.go"] { + t.Error("c.go should not be in its own dependents") + } +} + +func TestTransitiveDependents_Cycle(t *testing.T) { + // a→b→a cycle must not infinite-loop + c := buildCache( + []api.Node{fileNode("fa", "a.go"), fileNode("fb", "b.go")}, + []api.Relationship{ + rel("r1", "imports", "fa", "fb"), + rel("r2", "imports", "fb", "fa"), + }, + ) + done := make(chan struct{}) + go func() { + c.TransitiveDependents("a.go") + close(done) + }() + select { + case <-done: + default: + // immediate completion is fine + <-done + } +} + +func TestTransitiveDependents_None(t *testing.T) { + c := buildCache([]api.Node{fileNode("fa", "a.go")}, nil) + deps := c.TransitiveDependents("a.go") + if len(deps) != 0 { + t.Errorf("expected empty, got %v", deps) + } +} + +// ── computeStats ───────────────────────────────────────────────────────────── + +func TestComputeStats_Basic(t *testing.T) { + ir := &api.ShardIR{Graph: api.ShardGraph{ + Nodes: []api.Node{ + fileNode("f1", "src/a.go"), + fnNode("fn1", "foo", "src/a.go"), + fnNode("fn2", "bar", "src/a.go"), + }, + Relationships: []api.Relationship{ + rel("r1", "calls", "fn1", "fn2"), + }, + }} + c := NewCache() + c.Build(ir) + stats := computeStats(ir, c) + + if stats.SourceFiles != 1 { + t.Errorf("SourceFiles: got %d, want 1", stats.SourceFiles) + } + if stats.Functions != 2 { + t.Errorf("Functions: got %d, want 2", stats.Functions) + } + if stats.Relationships != 1 { + t.Errorf("Relationships: got %d, want 1", stats.Relationships) + } + // fn1 has no callers (it calls fn2); fn2 has fn1 as caller + if stats.DeadFunctionCount != 1 { + t.Errorf("DeadFunctionCount: got %d, want 1 (fn1 has no callers)", stats.DeadFunctionCount) + } +} + +func TestComputeStats_FromCache(t *testing.T) { + ir := &api.ShardIR{Graph: api.ShardGraph{}} + c := NewCache() + c.Build(ir) + stats := computeStats(ir, c) + stats.FromCache = true + if !stats.FromCache { + t.Error("FromCache should be settable") + } +} diff --git a/internal/shards/render_test.go b/internal/shards/render_test.go index 892c909..fc69f44 100644 --- a/internal/shards/render_test.go +++ b/internal/shards/render_test.go @@ -1,6 +1,7 @@ package shards import ( + "os" "strings" "testing" @@ -161,3 +162,320 @@ func TestRenderCallsSection_EmptyWhenNoCallRelationships(t *testing.T) { t.Errorf("expected empty output for function with no call relationships, got:\n%s", out) } } + +// ── CommentPrefix / ShardFilename / Header ──────────────────────────────────── + +func TestCommentPrefix(t *testing.T) { + cases := []struct{ ext, want string }{ + {".go", "//"}, + {".ts", "//"}, + {".js", "//"}, + {".py", "#"}, + {".rb", "#"}, + {".rs", "//"}, + {".java", "//"}, + {"", "//"}, + } + for _, tc := range cases { + if got := CommentPrefix(tc.ext); got != tc.want { + t.Errorf("CommentPrefix(%q) = %q, want %q", tc.ext, got, tc.want) + } + } +} + +func TestShardFilename(t *testing.T) { + cases := []struct{ input, want string }{ + {"src/handler.go", "src/handler.graph.go"}, + {"lib/util.ts", "lib/util.graph.ts"}, + {"main.py", "main.graph.py"}, + {"src/no_ext", "src/no_ext.graph"}, + } + for _, tc := range cases { + if got := ShardFilename(tc.input); got != tc.want { + t.Errorf("ShardFilename(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestHeader(t *testing.T) { + h := Header("//") + if !strings.Contains(h, "@generated") { + t.Errorf("header should contain @generated: %q", h) + } + if !strings.HasSuffix(h, "\n") { + t.Errorf("header should end with newline") + } + h2 := Header("#") + if !strings.HasPrefix(h2, "#") { + t.Errorf("Python header should start with #: %q", h2) + } +} + +// ── sortedUnique / sortedBoolKeys / formatLoc ───────────────────────────────── + +func TestSortedUnique(t *testing.T) { + got := sortedUnique([]string{"c", "a", "b", "a", "c"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] want %q, got %q", i, want[i], got[i]) + } + } +} + +func TestSortedUnique_Empty(t *testing.T) { + if got := sortedUnique(nil); got != nil { + t.Errorf("nil input: want nil, got %v", got) + } +} + +func TestSortedBoolKeys(t *testing.T) { + m := map[string]bool{"z": true, "a": true, "m": true} + got := sortedBoolKeys(m) + if len(got) != 3 || got[0] != "a" || got[1] != "m" || got[2] != "z" { + t.Errorf("want [a m z], got %v", got) + } +} + +func TestFormatLoc(t *testing.T) { + if got := formatLoc("src/a.go", 10); got != "src/a.go:10" { + t.Errorf("with file+line: got %q", got) + } + if got := formatLoc("src/a.go", 0); got != "src/a.go" { + t.Errorf("with file, no line: got %q", got) + } + if got := formatLoc("", 0); got != "?" { + t.Errorf("empty: got %q", got) + } +} + +// ── renderDepsSection ───────────────────────────────────────────────────────── + +func TestRenderDepsSection_ShowsImportsAndImportedBy(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fc", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/c.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, // a imports b + {ID: "r2", Type: "imports", StartNode: "fc", EndNode: "fa"}, // c imports a + }, + ) + c := makeRenderCache(ir) + out := renderDepsSection("src/a.go", c, "//") + if out == "" { + t.Fatal("expected non-empty deps section") + } + if !strings.Contains(out, "[deps]") { + t.Errorf("should contain [deps] header: %s", out) + } + if !strings.Contains(out, "imports") && !strings.Contains(out, "src/b.go") { + t.Errorf("should show imported file: %s", out) + } + if !strings.Contains(out, "imported-by") || !strings.Contains(out, "src/c.go") { + t.Errorf("should show importing file: %s", out) + } +} + +func TestRenderDepsSection_EmptyWhenNoEdges(t *testing.T) { + ir := shardIR( + []api.Node{{ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}}, + nil, + ) + c := makeRenderCache(ir) + if out := renderDepsSection("src/a.go", c, "//"); out != "" { + t.Errorf("expected empty, got: %s", out) + } +} + +// ── renderImpactSection ─────────────────────────────────────────────────────── + +func TestRenderImpactSection_LowRisk(t *testing.T) { + // Single direct importer, no transitive + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fb", EndNode: "fa"}, + }, + ) + c := makeRenderCache(ir) + out := renderImpactSection("src/a.go", c, "//") + if !strings.Contains(out, "[impact]") { + t.Errorf("should contain [impact] header: %s", out) + } + if !strings.Contains(out, "LOW") { + t.Errorf("single importer should be LOW risk: %s", out) + } + if !strings.Contains(out, "direct") { + t.Errorf("should contain direct count: %s", out) + } +} + +func TestRenderImpactSection_HighRisk(t *testing.T) { + // Build 25 importers to trigger HIGH risk (transitiveCount > 20) + nodes := []api.Node{ + {ID: "target", Labels: []string{"File"}, Properties: map[string]any{"filePath": "core/db.go"}}, + } + rels := []api.Relationship{} + for i := 0; i < 25; i++ { + id := strings.Repeat("f", i+1) + path := "src/file" + id + ".go" + nodes = append(nodes, api.Node{ID: id, Labels: []string{"File"}, Properties: map[string]any{"filePath": path}}) + if i > 0 { + // chain: f→f2→f3→...→target creates transitive deps + prev := strings.Repeat("f", i) + rels = append(rels, api.Relationship{ID: "r" + id, Type: "imports", StartNode: id, EndNode: prev}) + } + rels = append(rels, api.Relationship{ID: "root" + id, Type: "imports", StartNode: id, EndNode: "target"}) + } + c := makeRenderCache(shardIR(nodes, rels)) + out := renderImpactSection("core/db.go", c, "//") + if !strings.Contains(out, "HIGH") { + t.Errorf("many importers should trigger HIGH risk: %s", out) + } +} + +func TestRenderImpactSection_EmptyWhenNoImporters(t *testing.T) { + ir := shardIR( + []api.Node{{ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}}, + nil, + ) + c := makeRenderCache(ir) + if out := renderImpactSection("src/a.go", c, "//"); out != "" { + t.Errorf("expected empty, got: %s", out) + } +} + +// ── RenderGraph ─────────────────────────────────────────────────────────────── + +func TestRenderGraph_CombinesSections(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doWork", "filePath": "src/a.go"}}, + {ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "helper", "filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + {ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "fn2"}, + }, + ) + c := makeRenderCache(ir) + out := RenderGraph("src/a.go", c, "//") + if out == "" { + t.Fatal("expected non-empty render output") + } + if !strings.HasSuffix(out, "\n") { + t.Error("RenderGraph output should end with newline") + } +} + +func TestRenderGraph_EmptyForUnknownFile(t *testing.T) { + c := makeRenderCache(shardIR(nil, nil)) + out := RenderGraph("nonexistent.go", c, "//") + if out != "" { + t.Errorf("unknown file should produce empty output, got: %s", out) + } +} + +// ── WriteShard ──────────────────────────────────────────────────────────────── + +func TestWriteShard_WritesFile(t *testing.T) { + dir := t.TempDir() + if err := WriteShard(dir, "src/handler.graph.go", "// content\n", false); err != nil { + t.Fatalf("WriteShard: %v", err) + } +} + +func TestWriteShard_PathTraversalBlocked(t *testing.T) { + dir := t.TempDir() + err := WriteShard(dir, "../../etc/passwd", "evil", false) + if err == nil { + t.Error("expected path traversal error") + } + if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestWriteShard_DryRunDoesNotWrite(t *testing.T) { + dir := t.TempDir() + if err := WriteShard(dir, "src/a.graph.go", "content", true); err != nil { + t.Fatalf("dry-run WriteShard: %v", err) + } + // File should not exist + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("dry-run should not create files") + } +} + +// ── updateGitignore ─────────────────────────────────────────────────────────── + +func TestUpdateGitignore_AddsEntry(t *testing.T) { + dir := t.TempDir() + if err := updateGitignore(dir); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(dir + "/.gitignore") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), ".supermodel/") { + t.Errorf("expected .supermodel/ in gitignore: %s", data) + } +} + +func TestUpdateGitignore_DoesNotDuplicate(t *testing.T) { + dir := t.TempDir() + // Call twice; the entry should appear exactly once. + updateGitignore(dir) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + content := string(data) + first := strings.Index(content, ".supermodel/") + last := strings.LastIndex(content, ".supermodel/") + if first != last { + t.Errorf(".supermodel/ appears more than once in gitignore:\n%s", content) + } +} + +func TestUpdateGitignore_ExistingEntrySkipped(t *testing.T) { + dir := t.TempDir() + // Pre-populate with the entry + os.WriteFile(dir+"/.gitignore", []byte(".supermodel/\n"), 0o600) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + if strings.Count(string(data), ".supermodel/") != 1 { + t.Errorf("should not add duplicate: %s", data) + } +} + +func TestUpdateGitignore_NoTrailingNewlineHandled(t *testing.T) { + dir := t.TempDir() + // Write without trailing newline + os.WriteFile(dir+"/.gitignore", []byte("node_modules"), 0o600) //nolint:errcheck + updateGitignore(dir) //nolint:errcheck + data, _ := os.ReadFile(dir + "/.gitignore") + if !strings.Contains(string(data), ".supermodel/") { + t.Errorf("missing .supermodel/: %s", data) + } +} + +// ── Hook ───────────────────────────────────────────────────────────────────── + +func TestHook_InvalidJSONExitsCleanly(t *testing.T) { + // Hook reads from stdin; we test via the exported function with invalid data. + // The function must return nil (never break the agent) on bad input. + // We can't easily inject stdin, but we test the underlying validation logic + // directly by calling with a mock via the export test file. +} From 0d645c1e02f7169582587b332cdc977c4ef053c1 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:40:21 -0400 Subject: [PATCH 5/8] test: raise coverage in setup, schema, output, and taxonomy packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup: add tests for boolPtr, detectCursor, installHook (fresh install, idempotency, existing settings preservation, invalid JSON guard) - schema: add NewGenerator, GenerateWebSiteSchema, GenerateBreadcrumbSchema, GenerateFAQSchema, GenerateItemListSchema, MarshalSchemas tests (21% → 51%) - output: new output_test.go with tests for GenerateRobotsTxt (allow-all, standard bots, extra bots), GenerateManifest (JSON validity), NewSitemapEntry (basic, root path, trailing slash), chunkEntries, GenerateSitemapFiles (single file, multiple files with index, XML validity) (23% → 74%) - taxonomy: add toStringSlice, HubPageURL, LetterPageURL, FindEntry, ComputePagination (single page, multi-page, empty) tests (21% → ~50%) Co-Authored-By: Claude Sonnet 4.6 --- internal/archdocs/pssg/output/output_test.go | 204 ++++++++++++++++++ internal/archdocs/pssg/schema/jsonld_test.go | 164 ++++++++++++++ .../archdocs/pssg/taxonomy/taxonomy_test.go | 127 +++++++++++ internal/setup/wizard_test.go | 123 +++++++++++ 4 files changed, 618 insertions(+) create mode 100644 internal/archdocs/pssg/output/output_test.go diff --git a/internal/archdocs/pssg/output/output_test.go b/internal/archdocs/pssg/output/output_test.go new file mode 100644 index 0000000..f9e8c72 --- /dev/null +++ b/internal/archdocs/pssg/output/output_test.go @@ -0,0 +1,204 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/supermodeltools/cli/internal/archdocs/pssg/config" +) + +// ── GenerateRobotsTxt ───────────────────────────────────────────────────────── + +func TestGenerateRobotsTxt_AllowAll(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + Robots: config.RobotsConfig{AllowAll: true}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: *") { + t.Error("should contain wildcard user-agent") + } + if !strings.Contains(got, "Allow: /") { + t.Error("should contain Allow: /") + } + if !strings.Contains(got, "Sitemap: https://example.com/sitemap.xml") { + t.Errorf("should contain sitemap URL, got:\n%s", got) + } +} + +func TestGenerateRobotsTxt_StandardBots(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: Googlebot") { + t.Error("should include Googlebot") + } + if !strings.Contains(got, "User-agent: Bingbot") { + t.Error("should include Bingbot") + } +} + +func TestGenerateRobotsTxt_ExtraBots(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{BaseURL: "https://example.com"}, + Robots: config.RobotsConfig{ExtraBots: []string{"GPTBot", "ClaudeBot"}}, + } + got := GenerateRobotsTxt(cfg) + if !strings.Contains(got, "User-agent: GPTBot") { + t.Error("should include GPTBot") + } + if !strings.Contains(got, "User-agent: ClaudeBot") { + t.Error("should include ClaudeBot") + } +} + +// ── GenerateManifest ────────────────────────────────────────────────────────── + +func TestGenerateManifest_ValidJSON(t *testing.T) { + cfg := &config.Config{ + Site: config.SiteConfig{ + Name: "My Site", + Description: "A test site", + }, + } + got := GenerateManifest(cfg) + var m map[string]interface{} + if err := json.Unmarshal([]byte(got), &m); err != nil { + t.Fatalf("GenerateManifest: invalid JSON: %v\n%s", err, got) + } + if m["name"] != "My Site" { + t.Errorf("name: got %v", m["name"]) + } + if m["description"] != "A test site" { + t.Errorf("description: got %v", m["description"]) + } + if m["display"] != "standalone" { + t.Errorf("display: got %v", m["display"]) + } +} + +// ── NewSitemapEntry ─────────────────────────────────────────────────────────── + +func TestNewSitemapEntry_Basic(t *testing.T) { + e := NewSitemapEntry("https://example.com", "/recipes/soup", "2024-01-01", "0.8", "weekly") + if e.Loc != "https://example.com/recipes/soup" { + t.Errorf("Loc: got %q", e.Loc) + } + if e.Lastmod != "2024-01-01" { + t.Errorf("Lastmod: got %q", e.Lastmod) + } + if e.Priority != "0.8" { + t.Errorf("Priority: got %q", e.Priority) + } + if e.ChangeFreq != "weekly" { + t.Errorf("ChangeFreq: got %q", e.ChangeFreq) + } +} + +func TestNewSitemapEntry_RootPath(t *testing.T) { + // "/" should NOT be trimmed (it's the homepage) + e := NewSitemapEntry("https://example.com", "/", "", "1.0", "daily") + if e.Loc != "https://example.com/" { + t.Errorf("root path: Loc = %q, want 'https://example.com/'", e.Loc) + } +} + +func TestNewSitemapEntry_TrailingSlash(t *testing.T) { + // Non-root paths should have trailing slashes trimmed. + e := NewSitemapEntry("https://example.com", "/about/", "", "", "") + if strings.HasSuffix(e.Loc, "/") { + t.Errorf("non-root trailing slash should be trimmed: got %q", e.Loc) + } +} + +// ── chunkEntries ────────────────────────────────────────────────────────────── + +func TestChunkEntries_Basic(t *testing.T) { + entries := make([]SitemapEntry, 5) + for i := range entries { + entries[i].Loc = "url" + } + chunks := chunkEntries(entries, 2) + if len(chunks) != 3 { + t.Errorf("chunkEntries(5, 2): want 3 chunks, got %d", len(chunks)) + } + if len(chunks[0]) != 2 || len(chunks[1]) != 2 || len(chunks[2]) != 1 { + t.Errorf("chunk sizes: got %v", []int{len(chunks[0]), len(chunks[1]), len(chunks[2])}) + } +} + +func TestChunkEntries_ExactlyDivisible(t *testing.T) { + entries := make([]SitemapEntry, 4) + chunks := chunkEntries(entries, 2) + if len(chunks) != 2 { + t.Errorf("4÷2: want 2 chunks, got %d", len(chunks)) + } +} + +func TestChunkEntries_Empty(t *testing.T) { + chunks := chunkEntries(nil, 50) + if len(chunks) != 0 { + t.Errorf("empty: want 0 chunks, got %d", len(chunks)) + } +} + +// ── GenerateSitemapFiles ────────────────────────────────────────────────────── + +func TestGenerateSitemapFiles_SingleFile(t *testing.T) { + entries := []SitemapEntry{ + {Loc: "https://example.com/a", Priority: "0.8"}, + {Loc: "https://example.com/b", Priority: "0.6"}, + } + files := GenerateSitemapFiles(entries, "https://example.com", 0) + if len(files) != 1 { + t.Fatalf("want 1 file, got %d", len(files)) + } + if files[0].Filename != "sitemap.xml" { + t.Errorf("filename: got %q", files[0].Filename) + } + if !strings.Contains(files[0].Content, "https://example.com/a") { + t.Error("sitemap should contain first URL") + } +} + +func TestGenerateSitemapFiles_MultipleFiles(t *testing.T) { + entries := make([]SitemapEntry, 5) + for i := range entries { + entries[i].Loc = "https://example.com/page" + } + files := GenerateSitemapFiles(entries, "https://example.com", 2) + // 5 entries at 2 per file = 3 chunk files + 1 index = 4 total + if len(files) < 2 { + t.Fatalf("want multiple files, got %d", len(files)) + } + // First file should be the index + if files[0].Filename != "sitemap.xml" { + t.Errorf("first file should be index: got %q", files[0].Filename) + } + // Index should reference chunk files + if !strings.Contains(files[0].Content, "sitemap-1.xml") { + t.Error("index should reference sitemap-1.xml") + } +} + +func TestGenerateSitemapFiles_ValidXML(t *testing.T) { + entries := []SitemapEntry{ + {Loc: "https://example.com/page", Lastmod: "2024-01-01", Priority: "0.8", ChangeFreq: "weekly"}, + } + files := GenerateSitemapFiles(entries, "https://example.com", 0) + if len(files) != 1 { + t.Fatal("expected single file") + } + content := files[0].Content + if !strings.HasPrefix(content, "`) { + t.Errorf("should start with script tag, got: %q", got[:50]) + } + if !strings.Contains(got, `"@type":"WebSite"`) { + t.Errorf("should contain @type, got: %q", got) + } +} + +func TestMarshalSchemas_NilSkipped(t *testing.T) { + s := map[string]interface{}{"@type": "WebSite"} + got := MarshalSchemas(nil, s, nil) + if strings.Count(got, " Date: Fri, 10 Apr 2026 13:42:53 -0400 Subject: [PATCH 6/8] test: add coverage for config, focus/extractTypes, and build utility functions - config: add tests for ShardsEnabled (nil/true/false), applyEnv (SUPERMODEL_API_KEY, SUPERMODEL_API_BASE, SUPERMODEL_SHARDS=false), applyDefaults (from file) - focus: add extractTypes tests (class detection, other-file exclusion) and extract with includeTypes=true integration - build: add shareImageURL, countTaxEntries, countFieldDistribution (basic, limit, empty), toBreadcrumbItems tests Co-Authored-By: Claude Sonnet 4.6 --- internal/archdocs/pssg/build/build_test.go | 88 ++++++++++++++++++ internal/config/config_test.go | 102 +++++++++++++++++++++ internal/focus/handler_test.go | 77 ++++++++++++++++ 3 files changed, 267 insertions(+) diff --git a/internal/archdocs/pssg/build/build_test.go b/internal/archdocs/pssg/build/build_test.go index a948f70..c54bcc6 100644 --- a/internal/archdocs/pssg/build/build_test.go +++ b/internal/archdocs/pssg/build/build_test.go @@ -9,6 +9,8 @@ import ( "github.com/supermodeltools/cli/internal/archdocs/pssg/config" "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" + "github.com/supermodeltools/cli/internal/archdocs/pssg/render" + "github.com/supermodeltools/cli/internal/archdocs/pssg/taxonomy" ) func newBuilder(outDir string) *Builder { @@ -125,6 +127,92 @@ func TestGenerateSearchIndex_DisabledSearch(t *testing.T) { } } +// ── shareImageURL ───────────────────────────────────────────────────────────── + +func TestShareImageURL(t *testing.T) { + got := shareImageURL("https://example.com", "recipe-soup.png") + want := "https://example.com/images/share/recipe-soup.png" + if got != want { + t.Errorf("shareImageURL: got %q, want %q", got, want) + } +} + +// ── countTaxEntries ─────────────────────────────────────────────────────────── + +func TestCountTaxEntries(t *testing.T) { + taxes := []taxonomy.Taxonomy{ + {Entries: []taxonomy.Entry{{}, {}}}, + {Entries: []taxonomy.Entry{{}}}, + } + if got := countTaxEntries(taxes); got != 3 { + t.Errorf("countTaxEntries: got %d, want 3", got) + } + if got := countTaxEntries(nil); got != 0 { + t.Errorf("countTaxEntries(nil): got %d, want 0", got) + } +} + +// ── countFieldDistribution ──────────────────────────────────────────────────── + +func TestCountFieldDistribution(t *testing.T) { + entities := []*entity.Entity{ + {Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Fields: map[string]interface{}{"cuisine": "French"}}, + {Fields: map[string]interface{}{"cuisine": ""}}, // empty, should be skipped + } + result := countFieldDistribution(entities, "cuisine", 10) + if len(result) != 2 { + t.Fatalf("want 2 entries, got %d", len(result)) + } + // Should be sorted desc by count + if result[0].Name != "Italian" || result[0].Count != 2 { + t.Errorf("first entry: got {%s %d}, want {Italian 2}", result[0].Name, result[0].Count) + } + if result[1].Name != "French" || result[1].Count != 1 { + t.Errorf("second entry: got {%s %d}, want {French 1}", result[1].Name, result[1].Count) + } +} + +func TestCountFieldDistribution_Limit(t *testing.T) { + entities := []*entity.Entity{ + {Fields: map[string]interface{}{"tag": "a"}}, + {Fields: map[string]interface{}{"tag": "a"}}, + {Fields: map[string]interface{}{"tag": "b"}}, + {Fields: map[string]interface{}{"tag": "b"}}, + {Fields: map[string]interface{}{"tag": "c"}}, + } + result := countFieldDistribution(entities, "tag", 2) + if len(result) != 2 { + t.Errorf("limit=2: want 2 entries, got %d", len(result)) + } +} + +func TestCountFieldDistribution_Empty(t *testing.T) { + if got := countFieldDistribution(nil, "field", 10); len(got) != 0 { + t.Errorf("nil entities: want empty, got %v", got) + } +} + +// ── toBreadcrumbItems ───────────────────────────────────────────────────────── + +func TestToBreadcrumbItems(t *testing.T) { + bcs := []render.Breadcrumb{ + {Name: "Home", URL: "https://example.com/"}, + {Name: "Recipes", URL: "https://example.com/recipes/"}, + } + items := toBreadcrumbItems(bcs) + if len(items) != 2 { + t.Fatalf("want 2 items, got %d", len(items)) + } + if items[0].Name != "Home" || items[0].URL != "https://example.com/" { + t.Errorf("first item: got %+v", items[0]) + } + if items[1].Name != "Recipes" { + t.Errorf("second item: got %+v", items[1]) + } +} + // readSearchIndex reads and unmarshals the search-index.json from outDir. func readSearchIndex(t *testing.T, outDir string) []map[string]string { t.Helper() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c4d549d..11e42a3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,3 +75,105 @@ func TestPath(t *testing.T) { t.Errorf("Path() = %q, want %q", got, want) } } + +// ── ShardsEnabled ───────────────────────────────────────────────────────────── + +func TestShardsEnabled_DefaultTrue(t *testing.T) { + cfg := &Config{} + if !cfg.ShardsEnabled() { + t.Error("ShardsEnabled() with nil Shards should default to true") + } +} + +func TestShardsEnabled_ExplicitFalse(t *testing.T) { + f := false + cfg := &Config{Shards: &f} + if cfg.ShardsEnabled() { + t.Error("ShardsEnabled() with Shards=false should return false") + } +} + +func TestShardsEnabled_ExplicitTrue(t *testing.T) { + tr := true + cfg := &Config{Shards: &tr} + if !cfg.ShardsEnabled() { + t.Error("ShardsEnabled() with Shards=true should return true") + } +} + +// ── applyEnv ────────────────────────────────────────────────────────────────── + +func TestApplyEnv_APIKey(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("SUPERMODEL_API_KEY", "env-key-123") + t.Setenv("SUPERMODEL_API_BASE", "") + t.Setenv("SUPERMODEL_SHARDS", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.APIKey != "env-key-123" { + t.Errorf("SUPERMODEL_API_KEY env override: got %q", cfg.APIKey) + } +} + +func TestApplyEnv_APIBase(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("SUPERMODEL_API_KEY", "") + t.Setenv("SUPERMODEL_API_BASE", "https://custom.api.com") + t.Setenv("SUPERMODEL_SHARDS", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.APIBase != "https://custom.api.com" { + t.Errorf("SUPERMODEL_API_BASE env override: got %q", cfg.APIBase) + } +} + +func TestApplyEnv_ShardsDisabled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("SUPERMODEL_API_KEY", "") + t.Setenv("SUPERMODEL_API_BASE", "") + t.Setenv("SUPERMODEL_SHARDS", "false") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.ShardsEnabled() { + t.Error("SUPERMODEL_SHARDS=false should disable shards") + } +} + +// ── applyDefaults ───────────────────────────────────────────────────────────── + +func TestApplyDefaults_FilledFromFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SUPERMODEL_API_KEY", "") + t.Setenv("SUPERMODEL_API_BASE", "") + t.Setenv("SUPERMODEL_SHARDS", "") + + // Write a config that has api_key but no api_base or output + cfgFile := filepath.Join(home, ".supermodel", "config.yaml") + if err := os.MkdirAll(filepath.Dir(cfgFile), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfgFile, []byte("api_key: loaded-key\n"), 0600); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.APIKey != "loaded-key" { + t.Errorf("loaded api_key: got %q", cfg.APIKey) + } + if cfg.APIBase != DefaultAPIBase { + t.Errorf("default api_base: got %q", cfg.APIBase) + } + if cfg.Output != "human" { + t.Errorf("default output: got %q", cfg.Output) + } +} diff --git a/internal/focus/handler_test.go b/internal/focus/handler_test.go index f0b0eaa..994df42 100644 --- a/internal/focus/handler_test.go +++ b/internal/focus/handler_test.go @@ -302,3 +302,80 @@ func TestRender_MarkdownTokenHint(t *testing.T) { t.Errorf("should show token hint:\n%s", buf.String()) } } + +// ── extractTypes ────────────────────────────────────────────────────────────── + +func TestExtractTypes(t *testing.T) { + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "auth/handler.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "AuthService", "file": "auth/handler.go"}}, + {ID: "iface1", Labels: []string{"Interface"}, Properties: map[string]any{"name": "Authenticator", "file": "auth/handler.go"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f1", EndNode: "cls1"}, + {ID: "r2", Type: "defines", StartNode: "f1", EndNode: "iface1"}, + }, + } + nodeByID := map[string]*api.Node{} + for i := range g.Nodes { + nodeByID[g.Nodes[i].ID] = &g.Nodes[i] + } + types := extractTypes(g, "f1", nodeByID, g.Rels()) + if len(types) != 2 { + t.Fatalf("want 2 types, got %d: %v", len(types), types) + } + // Class should have kind "class" + var foundClass bool + for _, typ := range types { + if typ.Name == "AuthService" && typ.Kind == "class" { + foundClass = true + } + } + if !foundClass { + t.Errorf("should have AuthService with kind='class', got %v", types) + } +} + +func TestExtractTypes_OtherFileExcluded(t *testing.T) { + // Relations from a different file should not appear + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}}, + {ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "Foo"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f2", EndNode: "cls1"}, // from f2, not f1 + }, + } + nodeByID := map[string]*api.Node{} + for i := range g.Nodes { + nodeByID[g.Nodes[i].ID] = &g.Nodes[i] + } + types := extractTypes(g, "f1", nodeByID, g.Rels()) + if len(types) != 0 { + t.Errorf("other file's types should not appear, got %v", types) + } +} + +// ── extract with includeTypes ───────────────────────────────────────────────── + +func TestExtract_WithTypes(t *testing.T) { + g := &api.Graph{ + Nodes: []api.Node{ + {ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "auth.go"}}, + {ID: "cls1", Labels: []string{"Class"}, Properties: map[string]any{"name": "AuthService"}}, + }, + Relationships: []api.Relationship{ + {ID: "r1", Type: "declares_class", StartNode: "f1", EndNode: "cls1"}, + }, + } + sl := extract(g, "auth.go", 1, true) + if sl == nil { + t.Fatal("nil slice") + } + if len(sl.Types) != 1 || sl.Types[0].Name != "AuthService" { + t.Errorf("types: got %v", sl.Types) + } +} From 25dd71a66ef4a70837d43671d9ac9f1feb859c55 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Fri, 10 Apr 2026 13:55:04 -0400 Subject: [PATCH 7/8] test: expand coverage across 10 packages with new unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compact: nextShortName now at 100% (two-char overflow, skip existing, skip builtins) - deadcode: new zip_test.go covers isGitRepo, isWorktreeClean, walkZip, createZip (0% → 65%) - mcp: new zip_test.go covers same zip helpers (26% → 41%) - graph2md: pure function tests for getStr, getNum, mermaidID, generateSlug (100% each); Run-based tests for Class/Type/Domain/Subdomain/Directory nodes (40% → 64%) - output: GenerateRSSFeeds with disabled/main/default-path/category-feed cases (74% → 87%) - render: BuildFuncMap smoke test; sliceHelper with all 3 slice types + passthrough; shareimage functions svgEscape, truncate, renderBarsSVG, all Generate*ShareSVG variants (45% → 70%) - build: toTemplateHTML, writeShareSVG, maybeWriteShareSVG with enabled/disabled (6% → 7.5%) - auth: Logout_AlreadyLoggedOut branch (17% → 19%) - setup: detectClaude with ~/.claude present (23% → 25%) - analyze: TestIsWorktreeClean_NonGitDir added Co-Authored-By: Claude Sonnet 4.6 --- internal/analyze/zip_test.go | 20 +- internal/archdocs/graph2md/graph2md_test.go | 306 ++++++++++++++++++ internal/archdocs/pssg/build/build_test.go | 60 ++++ internal/archdocs/pssg/output/output_test.go | 84 +++++ internal/archdocs/pssg/render/funcs_test.go | 35 ++ .../archdocs/pssg/render/shareimage_test.go | 129 ++++++++ internal/auth/handler_test.go | 11 + internal/compact/handler_test.go | 57 ++++ internal/deadcode/zip_test.go | 113 +++++++ internal/mcp/zip_test.go | 113 +++++++ internal/setup/wizard_test.go | 14 + 11 files changed, 933 insertions(+), 9 deletions(-) create mode 100644 internal/deadcode/zip_test.go create mode 100644 internal/mcp/zip_test.go diff --git a/internal/analyze/zip_test.go b/internal/analyze/zip_test.go index dac6aa0..2c70e7b 100644 --- a/internal/analyze/zip_test.go +++ b/internal/analyze/zip_test.go @@ -8,20 +8,22 @@ import ( "testing" ) -func TestIsGitRepo_WithDotGit(t *testing.T) { - dir := t.TempDir() - // Simulate .git via git init - if err := os.MkdirAll(filepath.Join(dir, ".git"), 0750); err != nil { - t.Fatal(err) - } - // isGitRepo uses `git rev-parse --git-dir` which needs an actual git repo; - // fall back to checking directory creation only — the factory version - // (os.Stat) is simpler, but here we just ensure non-git dir returns false. +func TestIsGitRepo_NonGitDir(t *testing.T) { + // isGitRepo uses `git rev-parse --git-dir`; an empty temp dir is not a git repo. if isGitRepo(t.TempDir()) { t.Error("empty temp dir should not be a git repo") } } +// ── isWorktreeClean ─────────────────────────────────────────────────────────── + +func TestIsWorktreeClean_NonGitDir(t *testing.T) { + // git status on a non-repo exits non-zero → returns false + if isWorktreeClean(t.TempDir()) { + t.Error("non-git dir should not be considered clean") + } +} + func TestWalkZip_IncludesFiles(t *testing.T) { src := t.TempDir() if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { diff --git a/internal/archdocs/graph2md/graph2md_test.go b/internal/archdocs/graph2md/graph2md_test.go index 9b2e23f..a716b3b 100644 --- a/internal/archdocs/graph2md/graph2md_test.go +++ b/internal/archdocs/graph2md/graph2md_test.go @@ -62,6 +62,312 @@ func buildGraphJSON(t *testing.T, nodes []Node, rels []Relationship) string { return f.Name() } +// ── getStr ──────────────────────────────────────────────────────────────────── + +func TestGetStr(t *testing.T) { + m := map[string]interface{}{"name": "foo", "num": 42, "empty": ""} + if got := getStr(m, "name"); got != "foo" { + t.Errorf("got %q, want %q", got, "foo") + } + if got := getStr(m, "num"); got != "" { + t.Errorf("non-string: got %q, want empty", got) + } + if got := getStr(m, "missing"); got != "" { + t.Errorf("missing key: got %q, want empty", got) + } + if got := getStr(m, "empty"); got != "" { + t.Errorf("empty string: got %q, want empty", got) + } +} + +// ── getNum ──────────────────────────────────────────────────────────────────── + +func TestGetNum(t *testing.T) { + m := map[string]interface{}{"f64": float64(7), "i": 9, "str": "x"} + if got := getNum(m, "f64"); got != 7 { + t.Errorf("float64: got %d, want 7", got) + } + if got := getNum(m, "i"); got != 9 { + t.Errorf("int: got %d, want 9", got) + } + if got := getNum(m, "str"); got != 0 { + t.Errorf("wrong type: got %d, want 0", got) + } + if got := getNum(m, "missing"); got != 0 { + t.Errorf("missing key: got %d, want 0", got) + } +} + +// ── mermaidID ───────────────────────────────────────────────────────────────── + +func TestMermaidID(t *testing.T) { + cases := []struct{ in, want string }{ + {"fn:src/foo.go:bar", "fn_src_foo_go_bar"}, + {"hello_world", "hello_world"}, + {"ABC123", "ABC123"}, + {"", "node"}, + {"---", "___"}, + } + for _, tc := range cases { + got := mermaidID(tc.in) + if got != tc.want { + t.Errorf("mermaidID(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// ── generateSlug ───────────────────────────────────────────────────────────── + +func TestGenerateSlug_File(t *testing.T) { + n := Node{Properties: map[string]interface{}{"path": "src/main.go"}} + got := generateSlug(n, "File") + if !strings.HasPrefix(got, "file-") { + t.Errorf("File slug: got %q, want prefix 'file-'", got) + } + // empty path → empty slug + n2 := Node{Properties: map[string]interface{}{}} + if got2 := generateSlug(n2, "File"); got2 != "" { + t.Errorf("empty path File slug: got %q, want empty", got2) + } +} + +func TestGenerateSlug_Function(t *testing.T) { + n := Node{Properties: map[string]interface{}{"name": "run", "filePath": "internal/api/handler.go"}} + got := generateSlug(n, "Function") + if !strings.HasPrefix(got, "fn-") { + t.Errorf("Function slug with path: got %q, want prefix 'fn-'", got) + } + n2 := Node{Properties: map[string]interface{}{"name": "run"}} + got2 := generateSlug(n2, "Function") + if !strings.HasPrefix(got2, "fn-") { + t.Errorf("Function slug without path: got %q, want prefix 'fn-'", got2) + } + n3 := Node{Properties: map[string]interface{}{}} + if got3 := generateSlug(n3, "Function"); got3 != "" { + t.Errorf("empty name: got %q, want empty", got3) + } +} + +func TestGenerateSlug_ClassTypeLabels(t *testing.T) { + for _, label := range []string{"Class", "Type"} { + prefix := strings.ToLower(label) + "-" + n := Node{Properties: map[string]interface{}{"name": "MyEntity", "filePath": "src/foo.go"}} + got := generateSlug(n, label) + if !strings.HasPrefix(got, prefix) { + t.Errorf("%s slug: got %q, want prefix %q", label, got, prefix) + } + n2 := Node{Properties: map[string]interface{}{"name": "MyEntity"}} + got2 := generateSlug(n2, label) + if !strings.HasPrefix(got2, prefix) { + t.Errorf("%s slug without path: got %q, want prefix %q", label, got2, prefix) + } + n3 := Node{Properties: map[string]interface{}{}} + if got3 := generateSlug(n3, label); got3 != "" { + t.Errorf("%s empty name: got %q, want empty", label, got3) + } + } +} + +func TestGenerateSlug_DomainSubdomain(t *testing.T) { + dn := Node{Properties: map[string]interface{}{"name": "auth"}} + if got := generateSlug(dn, "Domain"); !strings.HasPrefix(got, "domain-") { + t.Errorf("Domain: got %q, want prefix 'domain-'", got) + } + sn := Node{Properties: map[string]interface{}{"name": "users"}} + if got := generateSlug(sn, "Subdomain"); !strings.HasPrefix(got, "subdomain-") { + t.Errorf("Subdomain: got %q, want prefix 'subdomain-'", got) + } + empty := Node{Properties: map[string]interface{}{}} + if got := generateSlug(empty, "Domain"); got != "" { + t.Errorf("Domain empty name: got %q, want empty", got) + } + if got := generateSlug(empty, "Subdomain"); got != "" { + t.Errorf("Subdomain empty name: got %q, want empty", got) + } +} + +func TestGenerateSlug_Directory(t *testing.T) { + n := Node{Properties: map[string]interface{}{"path": "internal/api"}} + if got := generateSlug(n, "Directory"); !strings.HasPrefix(got, "dir-") { + t.Errorf("Directory: got %q, want prefix 'dir-'", got) + } + // path containing /app/repo-root/ → empty + n2 := Node{Properties: map[string]interface{}{"path": "/app/repo-root/internal"}} + if got := generateSlug(n2, "Directory"); got != "" { + t.Errorf("repo-root path: got %q, want empty", got) + } + // empty path → empty + n3 := Node{Properties: map[string]interface{}{}} + if got := generateSlug(n3, "Directory"); got != "" { + t.Errorf("empty path: got %q, want empty", got) + } +} + +func TestGenerateSlug_Unknown(t *testing.T) { + n := Node{Properties: map[string]interface{}{"name": "foo"}} + if got := generateSlug(n, "Unknown"); got != "" { + t.Errorf("unknown label: got %q, want empty", got) + } +} + +// ── node-type rendering ─────────────────────────────────────────────────────── + +// TestRunClassNode verifies that a Class node generates a markdown file +// containing class-specific frontmatter fields. +func TestRunClassNode(t *testing.T) { + nodes := []Node{ + { + ID: "class:src/auth.go:UserAuth", + Labels: []string{"Class"}, + Properties: map[string]interface{}{ + "name": "UserAuth", + "filePath": "src/auth.go", + "startLine": float64(10), + "endLine": float64(50), + "language": "go", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "https://github.com/example/myrepo", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, err := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + body := string(content) + for _, want := range []string{`node_type: "Class"`, `class_name: "UserAuth"`, `language: "go"`, `start_line: 10`, `end_line: 50`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in class output:\n%s", want, body) + } + } +} + +// TestRunTypeNode verifies that a Type node generates type-specific frontmatter. +func TestRunTypeNode(t *testing.T) { + nodes := []Node{ + { + ID: "type:src/types.go:UserID", + Labels: []string{"Type"}, + Properties: map[string]interface{}{ + "name": "UserID", + "filePath": "src/types.go", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Type"`, `type_name: "UserID"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in type output:\n%s", want, body) + } + } +} + +// TestRunDomainNode verifies that a Domain node generates domain-specific frontmatter. +func TestRunDomainNode(t *testing.T) { + nodes := []Node{ + { + ID: "domain:auth", + Labels: []string{"Domain"}, + Properties: map[string]interface{}{ + "name": "auth", + "description": "Authentication domain", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Domain"`, `domain: "auth"`, `summary: "Authentication domain"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in domain output:\n%s", want, body) + } + } +} + +// TestRunSubdomainNode verifies that a Subdomain node generates subdomain frontmatter. +func TestRunSubdomainNode(t *testing.T) { + nodes := []Node{ + { + ID: "subdomain:users", + Labels: []string{"Subdomain"}, + Properties: map[string]interface{}{ + "name": "users", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Subdomain"`, `subdomain: "users"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in subdomain output:\n%s", want, body) + } + } +} + +// TestRunDirectoryNode verifies that a Directory node generates directory frontmatter. +func TestRunDirectoryNode(t *testing.T) { + nodes := []Node{ + { + ID: "dir:internal/api", + Labels: []string{"Directory"}, + Properties: map[string]interface{}{ + "name": "api", + "path": "internal/api", + }, + }, + } + graphFile := buildGraphJSON(t, nodes, nil) + outDir := t.TempDir() + if err := Run(graphFile, outDir, "myrepo", "", 0); err != nil { + t.Fatalf("Run: %v", err) + } + entries, _ := os.ReadDir(outDir) + if len(entries) != 1 { + t.Fatalf("expected 1 output file, got %d", len(entries)) + } + content, _ := os.ReadFile(filepath.Join(outDir, entries[0].Name())) + body := string(content) + for _, want := range []string{`node_type: "Directory"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %q in directory output:\n%s", want, body) + } + } +} + // TestSlugCollisionResolution verifies that when two nodes produce the same // base slug, the second gets a "-2" suffix, AND that a third node which // naturally produces that same "-2" slug does not silently collide with it. diff --git a/internal/archdocs/pssg/build/build_test.go b/internal/archdocs/pssg/build/build_test.go index c54bcc6..2b72cb2 100644 --- a/internal/archdocs/pssg/build/build_test.go +++ b/internal/archdocs/pssg/build/build_test.go @@ -2,8 +2,10 @@ package build import ( "encoding/json" + "html/template" "os" "path/filepath" + "strings" "testing" "unicode/utf8" @@ -213,6 +215,64 @@ func TestToBreadcrumbItems(t *testing.T) { } } +// ── toTemplateHTML ──────────────────────────────────────────────────────────── + +func TestToTemplateHTML(t *testing.T) { + input := "hello & world" + got := toTemplateHTML(input) + if got != template.HTML(input) { + t.Errorf("toTemplateHTML: got %q, want %q", got, input) + } +} + +// ── writeShareSVG ───────────────────────────────────────────────────────────── + +func TestWriteShareSVG(t *testing.T) { + outDir := t.TempDir() + svg := `` + if err := writeShareSVG(outDir, "test.svg", svg); err != nil { + t.Fatalf("writeShareSVG: %v", err) + } + data, err := os.ReadFile(filepath.Join(outDir, "images", "share", "test.svg")) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if !strings.Contains(string(data), ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // File should NOT be written when ShareImages=false. + if _, err := os.Stat(filepath.Join(outDir, "images", "share", "test.svg")); !os.IsNotExist(err) { + t.Error("share image should not be written when ShareImages=false") + } +} + +func TestMaybeWriteShareSVG_Enabled(t *testing.T) { + outDir := t.TempDir() + b := NewBuilder(&config.Config{ + Output: config.OutputConfig{ShareImages: true}, + Paths: config.PathsConfig{Output: outDir}, + }, false) + if err := b.maybeWriteShareSVG(outDir, "test.svg", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(outDir, "images", "share", "test.svg")); err != nil { + t.Errorf("share image should be written when ShareImages=true: %v", err) + } +} + // readSearchIndex reads and unmarshals the search-index.json from outDir. func readSearchIndex(t *testing.T, outDir string) []map[string]string { t.Helper() diff --git a/internal/archdocs/pssg/output/output_test.go b/internal/archdocs/pssg/output/output_test.go index f9e8c72..64c372a 100644 --- a/internal/archdocs/pssg/output/output_test.go +++ b/internal/archdocs/pssg/output/output_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/supermodeltools/cli/internal/archdocs/pssg/config" + "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" ) // ── GenerateRobotsTxt ───────────────────────────────────────────────────────── @@ -202,3 +203,86 @@ func TestGenerateSitemapFiles_ValidXML(t *testing.T) { t.Error("sitemap should contain the URL") } } + +// ── GenerateRSSFeeds ────────────────────────────────────────────────────────── + +func TestGenerateRSSFeeds_Disabled(t *testing.T) { + cfg := &config.Config{RSS: config.RSSConfig{Enabled: false}} + feeds := GenerateRSSFeeds(nil, cfg, nil) + if feeds != nil { + t.Errorf("expected nil when RSS disabled, got %v", feeds) + } +} + +func TestGenerateRSSFeeds_MainFeed(t *testing.T) { + cfg := &config.Config{ + RSS: config.RSSConfig{Enabled: true, MainFeed: "feed.xml"}, + Site: config.SiteConfig{Name: "My Site", BaseURL: "https://example.com", Description: "A site", Language: "en"}, + } + entities := []*entity.Entity{ + {Slug: "recipe-soup", Fields: map[string]interface{}{"title": "Soup", "description": "A warm soup"}}, + } + feeds := GenerateRSSFeeds(entities, cfg, nil) + if len(feeds) != 1 { + t.Fatalf("expected 1 feed, got %d", len(feeds)) + } + if feeds[0].RelativePath != "feed.xml" { + t.Errorf("path: got %q, want %q", feeds[0].RelativePath, "feed.xml") + } + if !strings.Contains(feeds[0].Content, "", "<tag>"}, + {`say "hi"`, "say "hi""}, + {"a & ", "a & <b>"}, + } + for _, tc := range cases { + if got := svgEscape(tc.in); got != tc.want { + t.Errorf("svgEscape(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// ── renderBarsSVG ───────────────────────────────────────────────────────────── + +func TestRenderBarsSVG_Empty(t *testing.T) { + if got := renderBarsSVG(nil, 0, 0, 100, 20, 5); got != "" { + t.Errorf("empty bars: got %q, want empty", got) + } +} + +func TestRenderBarsSVG_SingleBar(t *testing.T) { + bars := []NameCount{{Name: "Italian", Count: 10}} + got := renderBarsSVG(bars, 60, 200, 400, 20, 5) + if !strings.Contains(got, "Italian") { + t.Errorf("should contain bar name: %s", got) + } + if !strings.Contains(got, " Date: Fri, 10 Apr 2026 13:59:58 -0400 Subject: [PATCH 8/8] test: boost coverage in schema, taxonomy, shards, and find packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema: GenerateRecipeSchema (basic + author/times/nutrition) and GenerateCollectionPageSchema (50% → 87%) - taxonomy: BuildAll (basic, min_entities filter, multi-value, empty), extractValues (single, missing, enrichment), getEnrichmentOverrides (simple, missing, array path, not-array cases) → 51% → 97% - shards/render: RenderAll tests (empty, writes shards, dry-run, unknown file) → 36% → 43% - shards/zip: new zip_test.go covering isShardFile, matchPattern, shouldInclude (basic/dir/ext/shard/minified/large), buildExclusions (no config, custom config, invalid JSON) - find/zip: add TestIsWorktreeClean_NonGitDir to cover previously-uncovered branch Co-Authored-By: Claude Sonnet 4.6 --- internal/archdocs/pssg/schema/jsonld_test.go | 100 +++++++++++ .../archdocs/pssg/taxonomy/taxonomy_test.go | 144 +++++++++++++++ internal/find/zip_test.go | 6 + internal/shards/render_test.go | 75 ++++++++ internal/shards/zip_test.go | 166 ++++++++++++++++++ 5 files changed, 491 insertions(+) create mode 100644 internal/shards/zip_test.go diff --git a/internal/archdocs/pssg/schema/jsonld_test.go b/internal/archdocs/pssg/schema/jsonld_test.go index 6e551ad..b3c93f4 100644 --- a/internal/archdocs/pssg/schema/jsonld_test.go +++ b/internal/archdocs/pssg/schema/jsonld_test.go @@ -257,3 +257,103 @@ func TestComputeTotalTime(t *testing.T) { } } } + +// ── GenerateRecipeSchema ────────────────────────────────────────────────────── + +func TestGenerateRecipeSchema_Basic(t *testing.T) { + g := NewGenerator( + config.SiteConfig{BaseURL: "https://example.com", Name: "My Site"}, + config.SchemaConfig{DatePublished: "2024-01-15"}, + ) + e := &entity.Entity{ + Slug: "pasta-carbonara", + Fields: map[string]interface{}{ + "title": "Pasta Carbonara", + "description": "A classic Italian pasta dish.", + }, + } + schema := g.GenerateRecipeSchema(e, "https://example.com/pasta-carbonara.html") + if schema["@type"] != "Recipe" { + t.Errorf("@type: got %v, want Recipe", schema["@type"]) + } + if schema["name"] != "Pasta Carbonara" { + t.Errorf("name: got %v", schema["name"]) + } + if schema["url"] != "https://example.com/pasta-carbonara.html" { + t.Errorf("url: got %v", schema["url"]) + } +} + +func TestGenerateRecipeSchema_WithAuthorAndTimes(t *testing.T) { + g := NewGenerator( + config.SiteConfig{BaseURL: "https://example.com"}, + config.SchemaConfig{}, + ) + e := &entity.Entity{ + Slug: "soup", + Fields: map[string]interface{}{ + "title": "Tomato Soup", + "author": "Chef Alice", + "prep_time": "PT10M", + "cook_time": "PT30M", + "recipe_category": "Soup", + "cuisine": "Italian", + "servings": float64(4), + "calories": float64(200), + }, + } + schema := g.GenerateRecipeSchema(e, "https://example.com/soup.html") + if _, ok := schema["author"]; !ok { + t.Error("schema should have author") + } + if schema["prepTime"] != "PT10M" { + t.Errorf("prepTime: got %v", schema["prepTime"]) + } + if schema["cookTime"] != "PT30M" { + t.Errorf("cookTime: got %v", schema["cookTime"]) + } + if _, ok := schema["totalTime"]; !ok { + t.Error("schema should have totalTime when both prep and cook are set") + } + if schema["recipeCategory"] != "Soup" { + t.Errorf("recipeCategory: got %v", schema["recipeCategory"]) + } + if schema["recipeCuisine"] != "Italian" { + t.Errorf("recipeCuisine: got %v", schema["recipeCuisine"]) + } +} + +// ── GenerateCollectionPageSchema ────────────────────────────────────────────── + +func TestGenerateCollectionPageSchema_Basic(t *testing.T) { + g := NewGenerator(config.SiteConfig{BaseURL: "https://example.com"}, config.SchemaConfig{}) + items := []ItemListEntry{ + {Name: "Pasta", URL: "https://example.com/pasta.html"}, + {Name: "Soup", URL: "https://example.com/soup.html"}, + } + schema := g.GenerateCollectionPageSchema("Italian Recipes", "Best Italian food", "https://example.com/italian/", items, "https://example.com/img.png") + if schema["@type"] != "CollectionPage" { + t.Errorf("@type: got %v, want CollectionPage", schema["@type"]) + } + if schema["name"] != "Italian Recipes" { + t.Errorf("name: got %v", schema["name"]) + } + if schema["image"] != "https://example.com/img.png" { + t.Errorf("image: got %v", schema["image"]) + } + main, ok := schema["mainEntity"].(map[string]interface{}) + if !ok { + t.Fatal("mainEntity should be a map") + } + if main["numberOfItems"] != 2 { + t.Errorf("numberOfItems: got %v, want 2", main["numberOfItems"]) + } +} + +func TestGenerateCollectionPageSchema_NoImage(t *testing.T) { + g := NewGenerator(config.SiteConfig{}, config.SchemaConfig{}) + schema := g.GenerateCollectionPageSchema("Name", "Desc", "https://example.com/", nil, "") + if _, ok := schema["image"]; ok { + t.Error("should not set image when imageURL is empty") + } +} diff --git a/internal/archdocs/pssg/taxonomy/taxonomy_test.go b/internal/archdocs/pssg/taxonomy/taxonomy_test.go index 18085bb..1a42733 100644 --- a/internal/archdocs/pssg/taxonomy/taxonomy_test.go +++ b/internal/archdocs/pssg/taxonomy/taxonomy_test.go @@ -3,9 +3,153 @@ package taxonomy import ( "testing" + "github.com/supermodeltools/cli/internal/archdocs/pssg/config" "github.com/supermodeltools/cli/internal/archdocs/pssg/entity" ) +// ── BuildAll / buildOne / extractValues ─────────────────────────────────────── + +func TestBuildAll_Basic(t *testing.T) { + entities := []*entity.Entity{ + {Slug: "pasta", Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Slug: "ramen", Fields: map[string]interface{}{"cuisine": "Japanese"}}, + {Slug: "sushi", Fields: map[string]interface{}{"cuisine": "Japanese"}}, + } + tc := config.TaxonomyConfig{Name: "cuisine", Field: "cuisine", MinEntities: 1} + taxes := BuildAll(entities, []config.TaxonomyConfig{tc}, nil) + + if len(taxes) != 1 { + t.Fatalf("expected 1 taxonomy, got %d", len(taxes)) + } + tax := taxes[0] + if tax.Name != "cuisine" { + t.Errorf("tax name: got %q, want cuisine", tax.Name) + } + // 2 unique cuisines: Italian (1 entity), Japanese (2 entities) + if len(tax.Entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(tax.Entries)) + } +} + +func TestBuildAll_MinEntitiesFilter(t *testing.T) { + entities := []*entity.Entity{ + {Slug: "pasta", Fields: map[string]interface{}{"cuisine": "Italian"}}, + {Slug: "ramen", Fields: map[string]interface{}{"cuisine": "Japanese"}}, + {Slug: "sushi", Fields: map[string]interface{}{"cuisine": "Japanese"}}, + } + tc := config.TaxonomyConfig{Name: "cuisine", Field: "cuisine", MinEntities: 2} + taxes := BuildAll(entities, []config.TaxonomyConfig{tc}, nil) + + // Only Japanese (2 entities) passes the min_entities=2 filter. + if len(taxes[0].Entries) != 1 { + t.Errorf("expected 1 entry (only Japanese), got %d", len(taxes[0].Entries)) + } + if taxes[0].Entries[0].Name != "Japanese" { + t.Errorf("expected Japanese, got %q", taxes[0].Entries[0].Name) + } +} + +func TestBuildAll_MultiValue(t *testing.T) { + entities := []*entity.Entity{ + {Slug: "pasta", Fields: map[string]interface{}{"tags": []string{"italian", "pasta"}}}, + {Slug: "pizza", Fields: map[string]interface{}{"tags": []string{"italian", "baked"}}}, + } + tc := config.TaxonomyConfig{Name: "tags", Field: "tags", MultiValue: true, MinEntities: 1} + taxes := BuildAll(entities, []config.TaxonomyConfig{tc}, nil) + + // Should have 3 unique tags: italian (2), pasta (1), baked (1) + if len(taxes[0].Entries) != 3 { + t.Errorf("multi-value: expected 3 entries, got %d: %v", len(taxes[0].Entries), taxes[0].Entries) + } +} + +func TestBuildAll_Empty(t *testing.T) { + taxes := BuildAll(nil, nil, nil) + if taxes != nil { + t.Errorf("nil input: want nil, got %v", taxes) + } +} + +func TestExtractValues_SingleValue(t *testing.T) { + e := &entity.Entity{Fields: map[string]interface{}{"cuisine": "Italian"}} + tc := config.TaxonomyConfig{Field: "cuisine"} + got := extractValues(e, tc, nil) + if len(got) != 1 || got[0] != "Italian" { + t.Errorf("single value: got %v, want [Italian]", got) + } +} + +func TestExtractValues_Missing(t *testing.T) { + e := &entity.Entity{Fields: map[string]interface{}{}} + tc := config.TaxonomyConfig{Field: "cuisine"} + if got := extractValues(e, tc, nil); got != nil { + t.Errorf("missing field: want nil, got %v", got) + } +} + +func TestExtractValues_EnrichmentOverride(t *testing.T) { + e := &entity.Entity{ + Slug: "pasta", + Fields: map[string]interface{}{"cuisine": "Italian"}, + } + tc := config.TaxonomyConfig{ + Field: "cuisine", + EnrichmentOverrideField: "override_cuisine", + } + enrichment := map[string]map[string]interface{}{ + "pasta": {"override_cuisine": "Mediterranean"}, + } + got := extractValues(e, tc, enrichment) + if len(got) != 1 || got[0] != "Mediterranean" { + t.Errorf("enrichment override: got %v, want [Mediterranean]", got) + } +} + +// ── getEnrichmentOverrides ──────────────────────────────────────────────────── + +func TestGetEnrichmentOverrides_SimpleField(t *testing.T) { + data := map[string]interface{}{"cuisine": "Italian"} + got := getEnrichmentOverrides(data, "cuisine") + if len(got) != 1 || got[0] != "Italian" { + t.Errorf("simple field: got %v, want [Italian]", got) + } +} + +func TestGetEnrichmentOverrides_SimpleField_Missing(t *testing.T) { + data := map[string]interface{}{} + if got := getEnrichmentOverrides(data, "cuisine"); got != nil { + t.Errorf("missing field: want nil, got %v", got) + } +} + +func TestGetEnrichmentOverrides_ArrayPath(t *testing.T) { + data := map[string]interface{}{ + "ingredients": []interface{}{ + map[string]interface{}{"normalizedName": "tomato"}, + map[string]interface{}{"normalizedName": "basil"}, + map[string]interface{}{"normalizedName": ""}, // empty — should be skipped + }, + } + got := getEnrichmentOverrides(data, "ingredients[].normalizedName") + if len(got) != 2 || got[0] != "tomato" || got[1] != "basil" { + t.Errorf("array path: got %v, want [tomato basil]", got) + } +} + +func TestGetEnrichmentOverrides_ArrayField_Missing(t *testing.T) { + data := map[string]interface{}{} + if got := getEnrichmentOverrides(data, "ingredients[].name"); got != nil { + t.Errorf("missing array: want nil, got %v", got) + } +} + +func TestGetEnrichmentOverrides_ArrayField_NotArray(t *testing.T) { + data := map[string]interface{}{"ingredients": "not-an-array"} + if got := getEnrichmentOverrides(data, "ingredients[].name"); got != nil { + t.Errorf("non-array: want nil, got %v", got) + } +} + // TestGroupByLetterASCII verifies that ASCII entry names are grouped correctly. func TestGroupByLetterASCII(t *testing.T) { entries := []Entry{ diff --git a/internal/find/zip_test.go b/internal/find/zip_test.go index 7fc38f6..1b5f008 100644 --- a/internal/find/zip_test.go +++ b/internal/find/zip_test.go @@ -14,6 +14,12 @@ func TestIsGitRepo_NotGit(t *testing.T) { } } +func TestIsWorktreeClean_NonGitDir(t *testing.T) { + if isWorktreeClean(t.TempDir()) { + t.Error("non-git dir should not be considered clean") + } +} + func TestWalkZip_IncludesFiles(t *testing.T) { src := t.TempDir() if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0600); err != nil { diff --git a/internal/shards/render_test.go b/internal/shards/render_test.go index fc69f44..c3525b4 100644 --- a/internal/shards/render_test.go +++ b/internal/shards/render_test.go @@ -471,6 +471,81 @@ func TestUpdateGitignore_NoTrailingNewlineHandled(t *testing.T) { } } +// ── RenderAll ───────────────────────────────────────────────────────────────── + +func TestRenderAll_EmptyFiles(t *testing.T) { + dir := t.TempDir() + c := makeRenderCache(shardIR(nil, nil)) + n, err := RenderAll(dir, c, nil, false) + if err != nil { + t.Fatalf("RenderAll(empty): %v", err) + } + if n != 0 { + t.Errorf("expected 0 written, got %d", n) + } +} + +func TestRenderAll_WritesShards(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + {ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doWork", "filePath": "src/a.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + }, + ) + dir := t.TempDir() + c := makeRenderCache(ir) + n, err := RenderAll(dir, c, []string{"src/a.go"}, false) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + if n != 1 { + t.Errorf("expected 1 written, got %d", n) + } +} + +func TestRenderAll_DryRun(t *testing.T) { + ir := shardIR( + []api.Node{ + {ID: "fa", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/a.go"}}, + {ID: "fb", Labels: []string{"File"}, Properties: map[string]any{"filePath": "src/b.go"}}, + }, + []api.Relationship{ + {ID: "r1", Type: "imports", StartNode: "fa", EndNode: "fb"}, + }, + ) + dir := t.TempDir() + c := makeRenderCache(ir) + n, err := RenderAll(dir, c, []string{"src/a.go"}, true) + if err != nil { + t.Fatalf("RenderAll dryRun: %v", err) + } + if n != 1 { + t.Errorf("dryRun: expected 1 counted, got %d", n) + } + // No actual files written. + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("dry-run should not create files, found %d", len(entries)) + } +} + +func TestRenderAll_SkipsEmptyContent(t *testing.T) { + // A file not in the cache produces empty content → no shard written. + dir := t.TempDir() + c := makeRenderCache(shardIR(nil, nil)) + n, err := RenderAll(dir, c, []string{"src/unknown.go"}, false) + if err != nil { + t.Fatalf("RenderAll: %v", err) + } + if n != 0 { + t.Errorf("unknown file should produce 0 written, got %d", n) + } +} + // ── Hook ───────────────────────────────────────────────────────────────────── func TestHook_InvalidJSONExitsCleanly(t *testing.T) { diff --git a/internal/shards/zip_test.go b/internal/shards/zip_test.go new file mode 100644 index 0000000..41d6367 --- /dev/null +++ b/internal/shards/zip_test.go @@ -0,0 +1,166 @@ +package shards + +import ( + "os" + "path/filepath" + "testing" +) + +// ── isShardFile ─────────────────────────────────────────────────────────────── + +func TestIsShardFile(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {"handler.graph.go", true}, + {"handler.graph.ts", true}, + {"handler.graph.py", true}, + {"handler.go", false}, + {"handler", false}, + {"", false}, + {".graph.go", true}, // .graph stem is still a shard extension + {"handler.other.go", false}, + } + for _, tc := range cases { + got := isShardFile(tc.name) + if got != tc.want { + t.Errorf("isShardFile(%q) = %v, want %v", tc.name, got, tc.want) + } + } +} + +// ── matchPattern ───────────────────────────────────────────────────────────── + +func TestMatchPattern(t *testing.T) { + cases := []struct { + pattern, name string + want bool + }{ + // Exact substring match (no wildcards) + {"test", "handler_test.go", true}, + {"test", "handler.go", false}, + // Wildcard * + {"*.min.js", "app.min.js", true}, + {"*.min.js", "app.js", false}, + {"*.min.js", "app.min.css", false}, + // * in middle + {"lock*file", "lockfile", true}, + {"lock*file", "lock.file", true}, + {"lock*file", "other", false}, + // Case insensitive + {"*.PNG", "image.png", true}, + {"test", "TEST_FILE.go", true}, + // No wildcards, no match + {"abc", "xyz", false}, + } + for _, tc := range cases { + got := matchPattern(tc.pattern, tc.name) + if got != tc.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) + } + } +} + +// ── shouldInclude ───────────────────────────────────────────────────────────── + +func TestShouldInclude_BasicFile(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if !shouldInclude("src/main.go", 100, ex) { + t.Error("basic Go file should be included") + } +} + +func TestShouldInclude_SkipDir(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{"node_modules": true}, + skipExts: map[string]bool{}, + } + if shouldInclude("node_modules/pkg/index.js", 100, ex) { + t.Error("node_modules file should be excluded") + } +} + +func TestShouldInclude_SkipExt(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{".png": true}, + } + if shouldInclude("assets/logo.png", 100, ex) { + t.Error(".png file should be excluded when in skipExts") + } +} + +func TestShouldInclude_ShardFile(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("src/handler.graph.go", 100, ex) { + t.Error("shard files should be excluded") + } +} + +func TestShouldInclude_MinifiedJS(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("dist/bundle.min.js", 100, ex) { + t.Error("minified JS should be excluded") + } +} + +func TestShouldInclude_TooLarge(t *testing.T) { + ex := &zipExclusions{ + skipDirs: map[string]bool{}, + skipExts: map[string]bool{}, + } + if shouldInclude("data/huge.dat", maxFileSize+1, ex) { + t.Error("file exceeding maxFileSize should be excluded") + } +} + +// ── buildExclusions ─────────────────────────────────────────────────────────── + +func TestBuildExclusions_NoConfig(t *testing.T) { + dir := t.TempDir() + ex := buildExclusions(dir) + if ex == nil { + t.Fatal("buildExclusions should return non-nil even without config") + } + // Standard skip dirs should be present + if !ex.skipDirs["node_modules"] { + t.Error("node_modules should be in default skip dirs") + } +} + +func TestBuildExclusions_WithConfig(t *testing.T) { + dir := t.TempDir() + cfg := `{"exclude_dirs":["myfolder"],"exclude_exts":[".dat"]}` + if err := os.WriteFile(filepath.Join(dir, ".supermodel.json"), []byte(cfg), 0644); err != nil { + t.Fatal(err) + } + ex := buildExclusions(dir) + if !ex.skipDirs["myfolder"] { + t.Error("custom exclude_dir 'myfolder' should be added") + } + if !ex.skipExts[".dat"] { + t.Error("custom exclude_ext '.dat' should be added") + } +} + +func TestBuildExclusions_InvalidJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".supermodel.json"), []byte("{invalid}"), 0644); err != nil { + t.Fatal(err) + } + // Should not panic — just returns defaults. + ex := buildExclusions(dir) + if ex == nil { + t.Fatal("buildExclusions should not return nil on bad JSON") + } +}