package wiki import ( "context" "path/filepath" "strings" "testing" "time" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/graph" ) // buildToyGraph stands up a tiny graph with two communities so the // generator has something to render. Five nodes, three calls, two // files — enough to populate every page section. func buildToyGraph() *graph.Graph { g := graph.New() for _, n := range []*graph.Node{ {ID: "pkg/api.go::Handler", Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/api.go", StartLine: 10, Language: "go"}, {ID: "pkg/api.go::dispatch", Kind: graph.KindFunction, Name: "dispatch", FilePath: "pkg/api.go", StartLine: 20, Language: "go"}, {ID: "pkg/api.go::respond", Kind: graph.KindFunction, Name: "respond", FilePath: "pkg/api.go", StartLine: 30, Language: "go"}, {ID: "pkg/store.go::Save", Kind: graph.KindFunction, Name: "Save", FilePath: "pkg/store.go", StartLine: 10, Language: "go"}, {ID: "pkg/store.go::Load", Kind: graph.KindFunction, Name: "Load", FilePath: "pkg/store.go", StartLine: 20, Language: "go"}, } { g.AddNode(n) } for _, e := range []*graph.Edge{ {From: "pkg/api.go::Handler", To: "pkg/api.go::dispatch", Kind: graph.EdgeCalls, Origin: "ast_resolved"}, {From: "pkg/api.go::dispatch", To: "pkg/api.go::respond", Kind: graph.EdgeCalls, Origin: "ast_resolved"}, {From: "pkg/api.go::dispatch", To: "pkg/store.go::Save", Kind: graph.EdgeCalls, Origin: "lsp_resolved"}, {From: "pkg/api.go::dispatch", To: "pkg/store.go::Load", Kind: graph.EdgeCalls, Origin: "lsp_resolved"}, } { g.AddEdge(e) } return g } func TestGenerator_BasicFlow(t *testing.T) { g := buildToyGraph() communities := analysis.DetectCommunities(g) procs := analysis.DiscoverProcesses(g) hotspots := analysis.FindHotspots(g, communities, 0) cycles := analysis.DetectCycles(g, communities, "") tmp := t.TempDir() gen := New(Inputs{ Graph: g, Communities: communities, Processes: procs, Hotspots: hotspots, Cycles: cycles, }, Options{ OutputDir: tmp, Repo: "toy", MinCommunity: 1, }) result, _, err := gen.Generate(context.Background()) if err != nil { t.Fatalf("Generate: %v", err) } if result.OutputDir != tmp { t.Errorf("OutputDir = %q, want %q", result.OutputDir, tmp) } if len(result.Files) == 0 { t.Fatal("Generate returned no files") } if !strings.Contains(result.IndexMarkdown, "toy") { t.Errorf("top-level index should mention repo slug; got %q", result.IndexMarkdown) } if !strings.Contains(result.RepoIndexMarkdown, "Auto-generated by") { t.Errorf("repo index should contain auto-generated header; got %q", result.RepoIndexMarkdown) } // Spot-check the on-disk files. The architecture page must // embed a Mermaid block; the contracts page exists even with // zero contracts. mustExist := []string{ filepath.Join(tmp, "index.md"), filepath.Join(tmp, "toy", "index.md"), filepath.Join(tmp, "toy", "architecture.md"), filepath.Join(tmp, "toy", "analysis", "hotspots.md"), filepath.Join(tmp, "toy", "analysis", "cycles.md"), filepath.Join(tmp, "toy", "analysis", "semantic.md"), filepath.Join(tmp, "toy", "contracts", "api-surface.md"), filepath.Join(tmp, "toy", "_assets", "community-graph.mermaid"), filepath.Join(tmp, "_workspace", "README.md"), } files := gen.lastWriterFiles() for _, want := range mustExist { rel, _ := filepath.Rel(tmp, want) if _, ok := files[filepath.ToSlash(rel)]; !ok { t.Errorf("expected wiki to materialise %q", want) } } } func TestGenerator_Idempotent(t *testing.T) { g := buildToyGraph() communities := analysis.DetectCommunities(g) procs := analysis.DiscoverProcesses(g) tmp := t.TempDir() // Pin the generation timestamp so the front-matter generated_at is // stable across the two generations; without this the test flakes // whenever the two runs straddle a one-second boundary. fixedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) mk := func() *Result { gen := New(Inputs{ Graph: g, Communities: communities, Processes: procs, }, Options{OutputDir: tmp, Repo: "toy", MinCommunity: 1, GeneratedAt: fixedAt}) r, _, err := gen.Generate(context.Background()) if err != nil { t.Fatalf("Generate: %v", err) } return r } first := mk() second := mk() if len(first.Files) != len(second.Files) { t.Fatalf("file count differs: %d → %d", len(first.Files), len(second.Files)) } for i := range first.Files { if first.Files[i].Path != second.Files[i].Path { t.Errorf("file %d path differs: %q → %q", i, first.Files[i].Path, second.Files[i].Path) } if first.Files[i].SHA256 != second.Files[i].SHA256 { t.Errorf("file %d hash differs (%s): %s → %s", i, first.Files[i].Path, first.Files[i].SHA256, second.Files[i].SHA256) } } } func TestGenerator_SequenceDiagramEmitted(t *testing.T) { g := buildToyGraph() communities := analysis.DetectCommunities(g) procs := analysis.DiscoverProcesses(g) if len(procs.Processes) == 0 { t.Fatal("toy graph should produce at least one process") } tmp := t.TempDir() gen := New(Inputs{Graph: g, Communities: communities, Processes: procs}, Options{OutputDir: tmp, Repo: "toy", MinCommunity: 1}) if _, _, err := gen.Generate(context.Background()); err != nil { t.Fatalf("Generate: %v", err) } files := gen.lastWriterFiles() hasSequence := false for path, body := range files { if strings.HasPrefix(path, "toy/processes/") && strings.HasSuffix(path, ".md") { if strings.Contains(string(body), "sequenceDiagram") && (strings.Contains(string(body), "->>") || strings.Contains(string(body), "Note over")) { hasSequence = true } } } if !hasSequence { t.Error("expected at least one process page with a sequenceDiagram block") } } func TestGenerator_NoopEnhancerPreservesBody(t *testing.T) { g := buildToyGraph() communities := analysis.DetectCommunities(g) tmp := t.TempDir() gen := New(Inputs{Graph: g, Communities: communities}, Options{OutputDir: tmp, Repo: "toy", MinCommunity: 1, Enhance: true, Enhancer: NoopEnhancer{}}) r, _, err := gen.Generate(context.Background()) if err != nil { t.Fatalf("Generate: %v", err) } if !strings.Contains(r.RepoIndexMarkdown, "Auto-generated by") { t.Error("NoopEnhancer should preserve template content") } } func TestSlugify(t *testing.T) { for in, want := range map[string]string{ "FooBar": "foobar", "foo bar baz": "foo-bar-baz", " weird/_-id ": "weird-id", "": "", } { if got := slugify(in); got != want { t.Errorf("slugify(%q) = %q, want %q", in, got, want) } } } func TestRepoSlugFromPath(t *testing.T) { if got := RepoSlugFromPath("/abs/path/to/Repo"); got != "repo" { t.Errorf("RepoSlugFromPath = %q, want %q", got, "repo") } if got := RepoSlugFromPath(""); got != "repo" { t.Errorf("RepoSlugFromPath(\"\") = %q, want %q", got, "repo") } } // lastWriterFiles is a test-only accessor that re-runs the generator to // capture the in-memory file payload. Cheaper than reading the disk // and orderless. func (gen *Generator) lastWriterFiles() map[string][]byte { w := NewWriter(gen.opts.OutputDir) ctx := context.Background() repoSlug := gen.opts.Repo if repoSlug == "" { repoSlug = "repo" } // Re-render every page into the test writer. repoIndex, _ := gen.renderRepoIndex(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "index.md")), []byte(repoIndex)) w.Write("index.md", []byte(gen.renderTopLevelIndex())) arch, _ := gen.renderArchitecturePage(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "architecture.md")), []byte(arch)) for _, c := range gen.kept { body, _ := gen.renderCommunityPage(ctx, c) w.Write(filepath.ToSlash(filepath.Join(repoSlug, gen.communityPagePath(c))), []byte(body)) } if !gen.opts.NoProcesses && gen.processes != nil { for _, p := range gen.processes.Processes { body, _ := gen.renderProcessPage(ctx, p) w.Write(filepath.ToSlash(filepath.Join(repoSlug, processPagePath(p))), []byte(body)) } } if !gen.opts.NoContracts { body, _ := gen.renderContractsPage(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "contracts", "api-surface.md")), []byte(body)) } hs, _ := gen.renderHotspotsPage(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "analysis", "hotspots.md")), []byte(hs)) cy, _ := gen.renderCyclesPage(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "analysis", "cycles.md")), []byte(cy)) sm, _ := gen.renderSemanticPage(ctx) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "analysis", "semantic.md")), []byte(sm)) w.Write(filepath.ToSlash(filepath.Join(repoSlug, "_assets", "community-graph.mermaid")), []byte(RenderCommunityGraph(gen.graph, gen.communities, CommunityGraphOpts{MinSize: gen.opts.MinCommunity, Max: gen.opts.MaxCommunities}))) w.Write(filepath.ToSlash(filepath.Join("_workspace", "README.md")), []byte("# Workspace pages\n\nReserved for cross-repo wiki pages. Single-repo mode leaves this directory empty.\n")) if !gen.opts.NoDocs && gen.docsBundle != "" { w.Write(filepath.ToSlash(filepath.Join(repoSlug, "changelog.md")), []byte(gen.docsBundle)) } return w.Files() }