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
}
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/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/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/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 a948f70..2b72cb2 100644
--- a/internal/archdocs/pssg/build/build_test.go
+++ b/internal/archdocs/pssg/build/build_test.go
@@ -2,13 +2,17 @@ package build
import (
"encoding/json"
+ "html/template"
"os"
"path/filepath"
+ "strings"
"testing"
"unicode/utf8"
"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 +129,150 @@ 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])
+ }
+}
+
+// ── 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
new file mode 100644
index 0000000..64c372a
--- /dev/null
+++ b/internal/archdocs/pssg/output/output_test.go
@@ -0,0 +1,288 @@
+package output
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/supermodeltools/cli/internal/archdocs/pssg/config"
+ "github.com/supermodeltools/cli/internal/archdocs/pssg/entity"
+)
+
+// ── 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, " 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/archdocs/pssg/render/shareimage_test.go b/internal/archdocs/pssg/render/shareimage_test.go
index c984dd4..ac1d563 100644
--- a/internal/archdocs/pssg/render/shareimage_test.go
+++ b/internal/archdocs/pssg/render/shareimage_test.go
@@ -1,10 +1,139 @@
package render
import (
+ "strings"
"testing"
"unicode/utf8"
)
+// ── svgEscape ─────────────────────────────────────────────────────────────────
+
+func TestSvgEscape(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"hello", "hello"},
+ {"a & b", "a & b"},
+ {"", "<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, "`) {
+ 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, "