Files
summercms/internal/docsite/load_test.go
Jakub Zych 28afd4d197 test(11.1-06): branch-level tests for every docsite stage (94.8% coverage)
- load, render, emit, snippet, highlight, serve and checker branch tests;
  docs:build --check, docs:sync and docs:serve output tests in cmd/summer
- fix: a CRLF or BOM page is reported as such, not as a page without a
  frontmatter block (TestFrontmatterEncodingProblems)
- fix: src=#Type.Method finds a method with a parenthesised receiver
  (TestSnippetGenericsAndGroups, Box.Paren)
- fix: docs:serve adds its watches before announcing the server, so an
  edit made right after the serving line rebuilds (TestServeWatchRebuilds)
2026-10-01 00:05:12 +02:00

313 lines
13 KiB
Go

package docsite
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestParseSite(t *testing.T) {
const valid = "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n"
s, err := ParseSite([]byte(valid))
if err != nil {
t.Fatal(err)
}
if s.Title != "Acme" || len(s.Sections) != 1 || s.sectionTitle("setup") != "Setup" || s.sectionTitle("other") != "other" {
t.Fatalf("ParseSite = %+v", s)
}
for _, tc := range []struct{ name, raw, want string }{
{"unknown key", valid + "colour: red\n", "docsite: parse site config: "},
{"not a mapping", "- a\n- b\n", "docsite: parse site config: "},
{"no title", "description: d\nsections:\n - name: a\n title: A\n", "title is required"},
{"no description", "title: t\nsections:\n - name: a\n title: A\n", "description is required"},
{"no sections", "title: t\ndescription: d\n", "sections is required"},
{"section without title", "title: t\ndescription: d\nsections:\n - name: a\n", "every section needs a name and a title"},
{"reserved index", "title: t\ndescription: d\nsections:\n - name: index\n title: I\n", `section name "index" is reserved`},
{"bad slug", "title: t\ndescription: d\nsections:\n - name: Setup\n title: S\n", `section name "Setup" must be lowercase`},
{"duplicate", "title: t\ndescription: d\nsections:\n - name: a\n title: A\n - name: a\n title: B\n", `section "a" is listed twice`},
} {
if _, err := ParseSite([]byte(tc.raw)); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want)
}
}
if strings.Contains(func() string { _, err := ParseSite([]byte(valid + "colour: red\n")); return err.Error() }(), "\n") {
t.Error("a decode error must be reported on one line")
}
dir := t.TempDir()
path := filepath.Join(dir, "site.yaml")
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
t.Fatal(err)
}
if got, err := LoadSite(path); err != nil || got.Title != "Acme" {
t.Fatalf("LoadSite = %+v, %v", got, err)
}
if _, err := LoadSite(filepath.Join(dir, "missing.yaml")); err == nil || !strings.Contains(err.Error(), "read site config") {
t.Fatalf("LoadSite(missing) = %v", err)
}
}
func TestSplitFrontmatter(t *testing.T) {
for _, tc := range []struct {
name string
raw string
ok bool
fm, body string
bodyLine int
}{
{"plain", "---\ntitle: A\n---\n# A\n", true, "title: A\n", "# A\n", 4},
{"trailing spaces on the closing line", "---\ntitle: A\n--- \n# A\n", true, "title: A\n", "# A\n", 4},
{"no body", "---\ntitle: A\n---", true, "title: A\n", "", 4},
{"no body with newline", "---\ntitle: A\n---\n", true, "title: A\n", "", 4},
{"empty block", "---\n---\n# A\n", true, "", "# A\n", 3},
{"no closing delimiter", "---\ntitle: A\n# A\n", false, "", "", 0},
{"no opening delimiter", "title: A\n---\n# A\n", false, "", "", 0},
{"CRLF line endings", "---\r\ntitle: A\r\n---\r\n# A\r\n", false, "", "", 0},
{"byte order mark", "\xef\xbb\xbf---\ntitle: A\n---\n# A\n", false, "", "", 0},
} {
fm, body, line, ok := splitFrontmatter([]byte(tc.raw))
if ok != tc.ok || string(fm) != tc.fm || string(body) != tc.body || line != tc.bodyLine {
t.Errorf("%s: splitFrontmatter = %q, %q, %d, %v; want %q, %q, %d, %v",
tc.name, fm, body, line, ok, tc.fm, tc.body, tc.bodyLine, tc.ok)
}
}
}
// TestFrontmatterEncodingProblems pins the problem a CRLF or BOM page gets:
// it names the encoding, not a missing frontmatter block the file has.
func TestFrontmatterEncodingProblems(t *testing.T) {
crlf := strings.ReplaceAll(page("Win", "setup", 30, "Text.\n"), "\n", "\r\n")
bom := "\xef\xbb\xbf" + page("Bom", "setup", 40, "Text.\n")
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/win.md": crlf,
"docs/setup/bom.md": bom,
}), []string{
"docs/setup/bom.md:1: frontmatter: the file starts with a UTF-8 byte order mark; save it without one",
"docs/setup/win.md:1: frontmatter: the file uses CRLF line endings; save it with LF line endings",
})
}
func TestParseFrontmatter(t *testing.T) {
fm, details := parseFrontmatter([]byte("title: A\ndescription: B\nsection: setup\norder: 3\n"))
if len(details) > 0 || fm != (Frontmatter{Title: "A", Description: "B", Section: "setup", Order: 3}) {
t.Fatalf("parseFrontmatter = %+v %q", fm, details)
}
for _, tc := range []struct {
name, raw string
want []string
}{
{"unknown fields sorted, then missing", "title: A\nzeta: 1\nalpha: 2\nsection: s\norder: 1\n",
[]string{`unknown field "alpha"`, `unknown field "zeta"`, `missing field "description"`}},
{"empty and null values are missing", "title: \"\"\ndescription: ~\nsection: s\norder: 1\n",
[]string{`missing field "title"`, `missing field "description"`}},
{"everything missing", "{}\n",
[]string{`missing field "title"`, `missing field "description"`, `missing field "section"`, `missing field "order"`}},
} {
if _, got := parseFrontmatter([]byte(tc.raw)); !slices.Equal(got, tc.want) {
t.Errorf("%s: details = %q, want %q", tc.name, got, tc.want)
}
}
for _, raw := range []string{"- a\n- b\n", "title: A\ndescription: B\nsection: s\norder: x\n"} {
if _, got := parseFrontmatter([]byte(raw)); len(got) != 1 || strings.Contains(got[0], "\n") {
t.Errorf("parseFrontmatter(%q) = %q, want one single-line decode error", raw, got)
}
}
}
func TestWalkPagesExclusions(t *testing.T) {
root := writeTree(t, map[string]string{
"docs/index.md": "x",
"docs/setup/a.md": "x",
"docs/setup/examples/b.md": "x",
"docs/setup/notes.txt": "x",
"docs/setup/_draft.md": "x",
"docs/setup/.hidden.md": "x",
"docs/_partials/c.md": "x",
"docs/.cache/d.md": "x",
"docs/examples/blog/README.md": "x",
"docs/examples/blog/plugin.go": "x",
"docs/archive/examples/keep.md": "x",
})
files, err := walkPages(filepath.Join(root, "docs"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, f := range files {
rel, _ := filepath.Rel(root, f)
got = append(got, filepath.ToSlash(rel))
}
want := []string{"docs/archive/examples/keep.md", "docs/index.md", "docs/setup/a.md", "docs/setup/examples/b.md"}
if !slices.Equal(got, want) {
t.Fatalf("walkPages = %v, want %v", got, want)
}
if _, err := walkPages(filepath.Join(root, "nope")); err == nil {
t.Fatal("walkPages on a missing directory returned no error")
}
}
func TestSplitReadme(t *testing.T) {
for _, tc := range []struct {
name, raw string
ok bool
title, desc string
body string
}{
{"summary after blank lines", "# alpha\n\n\n Alpha does it. \n\n## Usage\n", true, "alpha", "Alpha does it.", "# alpha\n\n\n\n\n## Usage\n"},
{"summary on line two", "# alpha\nAlpha does it.\n", true, "alpha", "Alpha does it.", "# alpha\n\n"},
{"missing H1", "## alpha\n\nAlpha.\n", false, "", "", ""},
{"no heading at all", "alpha\n\nAlpha.\n", false, "", "", ""},
{"empty title", "# \n\nAlpha.\n", false, "", "", ""},
{"heading instead of summary", "# alpha\n\n## Usage\n", false, "", "", ""},
{"title only", "# alpha\n\n", false, "", "", ""},
{"empty file", "", false, "", "", ""},
} {
title, desc, body, ok := splitReadme([]byte(tc.raw))
if ok != tc.ok || title != tc.title || desc != tc.desc || string(body) != tc.body {
t.Errorf("%s: splitReadme = %q, %q, %q, %v", tc.name, title, desc, body, ok)
}
}
}
func TestModuleNames(t *testing.T) {
root := writeTree(t, map[string]string{
"modules/zeta/zeta.go": "package zeta\n",
"modules/alpha/alpha.go": "package alpha\n",
"modules/tests/only_test.go": "package tests\n",
"modules/nested/sub/sub.go": "package sub\n",
"modules/_skip/skip.go": "package skip\n",
"modules/.hidden/hidden.go": "package hidden\n",
"modules/file.go": "package modules\n",
"modules/docs/README.md": "# docs\n\nNo Go.\n",
"modules/gofolder.go/keep.md": "not a Go file\n",
})
names, err := moduleNames(root)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(names, []string{"alpha", "zeta"}) {
t.Fatalf("moduleNames = %v", names)
}
if names, err := moduleNames(t.TempDir()); err != nil || names != nil {
t.Fatalf("moduleNames without modules/ = %v, %v", names, err)
}
}
func TestLoadSectionRules(t *testing.T) {
// A module without an api section in site.yaml.
noAPI := "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n - name: \"empty\"\n title: Empty\n"
root := writeTree(t, map[string]string{
"docs/site.yaml": noAPI,
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
"modules/fixture/fixture.go": "package fixture\n",
"modules/fixture/README.md": "# fixture\n\nFixture.\n",
})
_, problems, err := Pages(Options{Root: root})
if err != nil {
t.Fatal(err)
}
assertProblems(t, problemLines(problems), []string{
`docs/site.yaml:1: section: "api" is not listed (every module README is published there)`,
`docs/site.yaml:6: section: "empty" has no pages (add the section in the same change as its first page)`,
})
// An invalid site.yaml stops the load with one site problem.
root = writeTree(t, map[string]string{"docs/site.yaml": "title: Acme\n"})
pages, problems, err := Pages(Options{Root: root})
if err != nil || pages != nil {
t.Fatalf("Pages = %v, %v", pages, err)
}
assertProblems(t, problemLines(problems), []string{"docs/site.yaml:1: site: site config: description is required"})
// A missing site.yaml is an error, not a problem.
if _, _, err := Pages(Options{Root: t.TempDir()}); err == nil || !strings.Contains(err.Error(), "read site config") {
t.Fatalf("Pages without site.yaml = %v", err)
}
if _, err := Check(Options{Root: t.TempDir(), Commands: fixtureCommands}); err == nil {
t.Fatal("Check without site.yaml returned no error")
}
if _, _, err := Build(Options{Root: t.TempDir(), Out: filepath.Join(t.TempDir(), "site"), Commands: fixtureCommands}); err == nil {
t.Fatal("Build without site.yaml returned no error")
}
}
func TestSectionLine(t *testing.T) {
raw := []byte("title: t\nsections:\n - name: setup\n title: S\n - name: 'quoted'\n title: Q\n - name: \"api\"\n")
for name, want := range map[string]int{"setup": 3, "quoted": 5, "api": 7, "missing": 1} {
if got := sectionLine(raw, name); got != want {
t.Errorf("sectionLine(%s) = %d, want %d", name, got, want)
}
}
}
func TestLoadOrderAndSources(t *testing.T) {
root := writeTree(t, map[string]string{
"docs/site.yaml": fixtureSite,
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/b.md": page("Bee", "setup", 10, "Text.\n"),
"docs/setup/a.md": page("Ay", "setup", 10+10, "Text.\n"),
"docs/setup/c.md": page("Cee", "setup", 5, "Text.\n"),
"modules/zeta/zeta.go": "package zeta\n",
"modules/zeta/README.md": "# zeta\n\nZeta.\n",
"modules/beta/beta.go": "package beta\n",
"modules/beta/README.md": "# beta\n\nBeta.\n",
})
pages, problems, err := Pages(Options{Root: root})
if err != nil || len(problems) > 0 {
t.Fatalf("Pages: %v %q", err, problemLines(problems))
}
var urls []string
for _, p := range pages {
urls = append(urls, p.URL)
}
if want := []string{"index", "setup/c", "setup/b", "setup/a", "api/beta", "api/zeta"}; !slices.Equal(urls, want) {
t.Fatalf("reading order = %v, want %v", urls, want)
}
if pages[1].Source != "docs/setup/c.md" || pages[1].BodyLine != 7 || !strings.HasPrefix(string(pages[1].Body), "# Cee\n") {
t.Fatalf("setup/c = %+v", pages[1])
}
// A Src outside the root is shown as an absolute path.
src := filepath.Join(t.TempDir(), "docs")
if err := os.Rename(filepath.Join(root, "docs"), src); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(src, "setup/c.md")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "setup/c.md"), []byte("no frontmatter\n"), 0o644); err != nil {
t.Fatal(err)
}
_, problems, err = Pages(Options{Root: root, Src: src})
if err != nil {
t.Fatal(err)
}
want := filepath.ToSlash(filepath.Join(src, "setup/c.md")) + `:1: frontmatter: the file must start with a "---" frontmatter block closed by a "---" line`
assertProblems(t, problemLines(problems), []string{want})
}
func TestNormalizeDefaults(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
o, err := Options{}.normalize()
if err != nil {
t.Fatal(err)
}
if o.Root != wd || o.Src != filepath.Join(wd, "docs") || o.Out != filepath.Join(wd, "site") {
t.Fatalf("normalize() = %+v", o)
}
o, err = Options{Root: "a/../b", Src: "x", Out: "y/"}.normalize()
if err != nil {
t.Fatal(err)
}
if o.Root != filepath.Join(wd, "b") || o.Src != filepath.Join(wd, "x") || o.Out != filepath.Join(wd, "y") {
t.Fatalf("normalize(relative) = %+v", o)
}
}