| // Copyright 2026 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package frontend |
| |
| import ( |
| "context" |
| "go/ast" |
| "go/parser" |
| "go/token" |
| "testing" |
| "time" |
| |
| "github.com/google/go-cmp/cmp" |
| "golang.org/x/pkgsite/internal" |
| "golang.org/x/pkgsite/internal/godoc" |
| "golang.org/x/pkgsite/internal/licenses" |
| "golang.org/x/pkgsite/internal/testing/fakedatasource" |
| "golang.org/x/pkgsite/internal/testing/sample" |
| ) |
| |
| func TestFetchEvalsDetails(t *testing.T) { |
| ctx := context.Background() |
| |
| mit := &licenses.Metadata{Types: []string{"MIT"}, FilePath: "LICENSE"} |
| mitLicense := &licenses.License{ |
| Metadata: mit, |
| Contents: []byte("MIT License"), |
| } |
| |
| tests := []struct { |
| name string |
| version string |
| isRedistributable bool |
| licenses []*licenses.License |
| want []eval |
| }{ |
| { |
| name: "no license, untagged pseudo version", |
| version: "v0.0.0-20260101120000-abcdef123456", |
| isRedistributable: false, |
| licenses: nil, |
| want: []eval{ |
| { |
| Type: licenseEval, |
| Score: 0, |
| Value: "no license", |
| }, |
| { |
| Type: moduleVersionEval, |
| Score: 0, |
| Value: "untagged", |
| }, |
| }, |
| }, |
| { |
| name: "no license, prerelease", |
| version: "v1.2.3-alpha", |
| isRedistributable: false, |
| licenses: nil, |
| want: []eval{ |
| { |
| Type: licenseEval, |
| Score: 0, |
| Value: "no license", |
| }, |
| { |
| Type: moduleVersionEval, |
| Score: 1, |
| Value: "tagged, unstable", |
| }, |
| }, |
| }, |
| { |
| name: "non-redistributable license, unstable v0 tagged version", |
| version: "v0.1.0", |
| isRedistributable: false, |
| licenses: []*licenses.License{mitLicense}, |
| want: []eval{ |
| { |
| Type: licenseEval, |
| Score: 1, |
| Value: "non-redistributable license", |
| }, |
| { |
| Type: moduleVersionEval, |
| Score: 1, |
| Value: "tagged, unstable", |
| }, |
| }, |
| }, |
| { |
| name: "redistributable license, stable v1 tagged version", |
| version: "v1.2.3", |
| isRedistributable: true, |
| licenses: []*licenses.License{mitLicense}, |
| want: []eval{ |
| { |
| Type: licenseEval, |
| Score: 2, |
| Value: "redistributable license", |
| }, |
| { |
| Type: moduleVersionEval, |
| Score: 2, |
| Value: "tagged, stable", |
| }, |
| }, |
| }, |
| } |
| |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| fds := fakedatasource.New() |
| mod := sample.Module(sample.ModulePath, tt.version, sample.Suffix) |
| mod.IsRedistributable = tt.isRedistributable |
| mod.Licenses = tt.licenses |
| for _, u := range mod.Units { |
| u.IsRedistributable = tt.isRedistributable |
| u.LicenseContents = tt.licenses |
| } |
| fds.MustInsertModule(t, mod) |
| |
| um := &internal.UnitMeta{ |
| Path: sample.ModulePath + "/" + sample.Suffix, |
| ModuleInfo: mod.ModuleInfo, |
| } |
| |
| got, err := fetchEvalsDetails(ctx, fds, um) |
| if err != nil { |
| t.Fatalf("fetchEvalsDetails() error = %v", err) |
| } |
| if diff := cmp.Diff(tt.want, got.Evals, cmp.AllowUnexported(eval{}, evalType{})); diff != "" { |
| t.Errorf("fetchEvalsDetails() mismatch (-want +got):\n%s", diff) |
| } |
| }) |
| } |
| } |
| |
| func TestAgeString(t *testing.T) { |
| testCases := []struct { |
| d time.Duration |
| want string |
| }{ |
| {0, "less than a day"}, |
| {12 * time.Hour, "less than a day"}, |
| {23*time.Hour + 59*time.Minute, "less than a day"}, |
| {day, "1 day"}, |
| {2 * day, "2 days"}, |
| {23 * day, "23 days"}, |
| {30 * day, "30 days"}, |
| {31 * day, "1 month"}, |
| {31*day + 1*day, "1 month, 1 day"}, |
| {31*day + 5*day, "1 month, 5 days"}, |
| {2 * month, "2 months"}, |
| {2*month + 1*day, "2 months, 1 day"}, |
| {2*month + 3*day, "2 months, 3 days"}, |
| {11*month + 23*day, "11 months, 23 days"}, |
| {year, "1 year"}, |
| {year + 1*day, "1 year"}, // 1 day is 0 months remainder |
| {year + 1*month, "1 year, 1 month"}, |
| {year + 2*month + 5*day, "1 year, 2 months"}, |
| {2 * year, "2 years"}, |
| {2*year + 5*month, "2 years, 5 months"}, |
| {2*year + 1*month + 30*day, "2 years, 1 month"}, |
| } |
| |
| for _, tc := range testCases { |
| t.Run(tc.want, func(t *testing.T) { |
| got := ageString(tc.d) |
| if got != tc.want { |
| t.Errorf("ageString(%v) = %q, want %q", tc.d, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestSummarizeDocumentation(t *testing.T) { |
| testCases := []struct { |
| name string |
| files map[string]string |
| want docSummary |
| }{ |
| { |
| name: "nil or empty", |
| files: nil, |
| want: docSummary{}, |
| }, |
| { |
| name: "only test files ignored", |
| files: map[string]string{ |
| "foo_test.go": `package foo |
| // TestSomething tests something. |
| func TestSomething(t *testing.T) {}`, |
| }, |
| want: docSummary{}, |
| }, |
| { |
| name: "main package ignored", |
| files: map[string]string{ |
| "main.go": `package main |
| // Package main is executable. |
| func main() {}`, |
| }, |
| want: docSummary{}, |
| }, |
| { |
| name: "package has doc", |
| files: map[string]string{ |
| "doc.go": `// Package mypkg provides utility routines. |
| package mypkg`, |
| }, |
| want: docSummary{ |
| packageHasDoc: true, |
| }, |
| }, |
| { |
| name: "package doesn't have doc", |
| files: map[string]string{ |
| "doc.go": `package mypkg`, |
| }, |
| want: docSummary{ |
| packageHasDoc: false, |
| }, |
| }, |
| { |
| name: "all exported symbols documented", |
| files: map[string]string{ |
| "mypkg.go": `// Package mypkg provides utilities. |
| package mypkg |
| |
| // F is a function. |
| func F() {} |
| |
| // T is a type. |
| type T int |
| `, |
| }, |
| want: docSummary{ |
| packageHasDoc: true, |
| numExportedSymbols: 2, |
| numHaveDoc: 2, |
| }, |
| }, |
| { |
| name: "no exported symbols documented", |
| files: map[string]string{ |
| "mypkg.go": `package mypkg |
| |
| func F() {} |
| |
| type T int |
| `, |
| }, |
| want: docSummary{ |
| packageHasDoc: false, |
| numExportedSymbols: 2, |
| numHaveDoc: 0, |
| }, |
| }, |
| { |
| name: "mixed documented and undocumented exported symbols", |
| files: map[string]string{ |
| "mypkg.go": `// Package mypkg provides utilities. |
| package mypkg |
| |
| // ExportedWithDoc has doc comment. |
| func ExportedWithDoc() {} |
| |
| func ExportedNoDoc() {} |
| |
| // ExportedType has doc. |
| type ExportedType struct{} |
| |
| func (ExportedType) UndocumentedMethod() {} |
| |
| type unexportedType struct{} |
| |
| func (unexportedType) MethodOnUnexported() {} |
| `, |
| }, |
| want: docSummary{ |
| packageHasDoc: true, |
| numExportedSymbols: 4, |
| numHaveDoc: 2, |
| }, |
| }, |
| { |
| name: "symbols across multiple files", |
| files: map[string]string{ |
| "doc.go": `// Package mypkg provides utilities. |
| package mypkg |
| |
| // Helper function. |
| func Helper() {} |
| `, |
| "types.go": `package mypkg |
| |
| type Config struct{} |
| |
| const DefaultTimeout = 10 |
| `, |
| }, |
| want: docSummary{ |
| packageHasDoc: true, |
| numExportedSymbols: 3, |
| numHaveDoc: 1, |
| }, |
| }, |
| } |
| |
| for _, tc := range testCases { |
| t.Run(tc.name, func(t *testing.T) { |
| var pkg *godoc.Package |
| if tc.files != nil { |
| pkg = parseTestPackage(t, tc.files) |
| } |
| got := summarizeDocumentation(pkg) |
| if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(docSummary{})); diff != "" { |
| t.Errorf("summarizeDocumentation() mismatch (-want +got):\n%s", diff) |
| } |
| }) |
| } |
| } |
| |
| // parseTestPackage creates a godoc.Package with the given files. It removes the |
| // bodies of functions, encodes and then decodes the package, to simulate what the |
| // frontend actually observes. It returns the decoded package. |
| func parseTestPackage(t *testing.T, files map[string]string) *godoc.Package { |
| t.Helper() |
| fset := token.NewFileSet() |
| docPkg := godoc.NewPackage(fset, nil) |
| for name, src := range files { |
| f, err := parser.ParseFile(fset, name, src, parser.ParseComments) |
| if err != nil { |
| t.Fatalf("parser.ParseFile(%q): %v", name, err) |
| } |
| docPkg.AddFile(f, true) |
| } |
| bytes, err := docPkg.Encode(context.Background()) |
| if err != nil { |
| t.Fatalf("docPkg.Encode: %v", err) |
| } |
| decodedPkg, err := godoc.DecodePackage(bytes) |
| if err != nil { |
| t.Fatalf("godoc.DecodePackage: %v", err) |
| } |
| return decodedPkg |
| } |
| |
| func TestCollectSymbols(t *testing.T) { |
| src := `package mypkg |
| |
| // ExportedFunc has doc. |
| func ExportedFunc() {} |
| |
| func unexportedFunc() {} |
| |
| // ExportedType has doc. |
| type ExportedType struct{} |
| |
| type unexportedType struct{} |
| |
| // ExportedMethod has doc. |
| func (e ExportedType) ExportedMethod() {} |
| |
| func (e ExportedType) UndocumentedMethod() {} |
| |
| // MethodOnUnexportedType should be ignored. |
| func (u unexportedType) MethodOnUnexportedType() {} |
| |
| // ExportedConst has doc. |
| const ExportedConst = 1 |
| |
| const UndocumentedConst = 2 |
| |
| // SingleGroupedType applies doc to single spec. |
| type SingleGroupedType int |
| |
| var ( |
| // ExportedVar has doc. |
| ExportedVar = "a" |
| UndocumentedVar = "b" // UndocumentedVar has line doc |
| NoDocVar = "c" |
| unexportedVar = "d" |
| ) |
| ` |
| |
| pkg := parseTestPackage(t, map[string]string{"mypkg.go": src}) |
| var files []*ast.File |
| for _, f := range pkg.Files { |
| if f != nil && f.AST != nil { |
| files = append(files, f.AST) |
| } |
| } |
| |
| got := make(map[string]bool) |
| collectSymbols(files, func(name string, has bool) { |
| got[name] = has |
| }) |
| |
| want := map[string]bool{ |
| "ExportedFunc": true, |
| "ExportedType": true, |
| "(ExportedType).ExportedMethod": true, |
| "(ExportedType).UndocumentedMethod": false, |
| "ExportedConst": true, |
| "UndocumentedConst": false, |
| "SingleGroupedType": true, |
| "ExportedVar": true, |
| "UndocumentedVar": true, |
| "NoDocVar": false, |
| } |
| |
| if diff := cmp.Diff(want, got); diff != "" { |
| t.Errorf("collectSymbols() mismatch (-want +got):\n%s", diff) |
| } |
| } |