Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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()
}