a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
248 lines
9.0 KiB
Go
248 lines
9.0 KiB
Go
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()
|
|
}
|