338 lines
12 KiB
Go
338 lines
12 KiB
Go
package site
|
||
|
||
import (
|
||
"encoding/json"
|
||
"flag"
|
||
"html"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
var update = flag.Bool("update", false, "rewrite the navigation goldens")
|
||
|
||
var (
|
||
hrefRe = regexp.MustCompile(`href="([^"]*)"`)
|
||
idRe = regexp.MustCompile(`id="([^"]*)"`)
|
||
)
|
||
|
||
func fixture() *Export {
|
||
return &Export{
|
||
Version: 2,
|
||
Title: "Aego",
|
||
Entries: []Entry{
|
||
{ID: "welcome", Kind: KindPage, Group: "start", Title: "Getting started", Summary: "First steps.",
|
||
Body: "# Getting started\n\n## Install\n\nGet Go.\n\n## Run\n\nRun it.",
|
||
Location: Location{Path: "engine:welcome.md", Line: 1, Source: "engine/res/docs/welcome.md"}},
|
||
{ID: "language", Kind: KindPage, Group: "script", Title: "Aego Script", Summary: "The language.", Body: "# Aego Script\n\nSee [coroutines](ref:language/coroutines)."},
|
||
{ID: "language/coroutines", Parent: "language", Kind: KindPage, Group: "script", Title: "Coroutines", Body: "# Coroutines\n\nPause with [yield](ref:keyword/yield), clamp with `aego/math.Clamp`, fails with `AES2050`.\n\n```aes run\npackage demo\n```\n"},
|
||
{ID: "language/builtins", Parent: "language", Kind: KindPage, Group: "script", Title: "Keywords", Body: "# Keywords\n\nAll of them."},
|
||
{ID: "keyword/yield", Parent: "language/builtins", Kind: KindPage, Group: "script", Title: "yield", Body: "yield pauses.\n\n```aes\nyield wait(0.1)\n```"},
|
||
{ID: "aego/math", Kind: KindModule, Group: "ref-script", Title: "aego/math"},
|
||
{ID: "aego/math.Clamp", Parent: "aego/math", Kind: KindFunction, Group: "ref-script", Title: "Clamp", Signature: "func Clamp(x float, lo float, hi float) float", Body: "Clamp limits x.", Example: "math.Clamp(v, 0, 1)"},
|
||
{ID: "module/script", Kind: KindModule, Group: "components", Title: "Aego Script"},
|
||
{ID: "engine.SpriteRenderer", Parent: "module/script", Kind: KindType, Group: "components", Title: "Sprite Renderer", Body: "Draws a sprite."},
|
||
{ID: "engine.SpriteRenderer.Tint", Parent: "engine.SpriteRenderer", Kind: KindField, Group: "components", Title: "Tint", Body: "Color."},
|
||
{ID: "diagnostics", Kind: KindPage, Group: "diagnostics", Title: "Diagnostics", Body: "Every code."},
|
||
{ID: "AES2050", Parent: "diagnostics", Kind: KindCode, Group: "diagnostics", Title: "AES2050", Body: "yield outside a coroutine.\n\nFix: use `coroutine`."},
|
||
{ID: "project", Kind: KindPage, Group: "project", Title: "My game", Body: "Script packages."},
|
||
{ID: "scripts/game/player", Parent: "project", Kind: KindModule, Group: "project", Title: "game/player", Location: Location{Path: "scripts/player/player.aes", Line: 3}},
|
||
},
|
||
Groups: []GroupNav{
|
||
{Group: Group{ID: "ref-script", Title: "Script API", Section: SectionReference, Order: 100},
|
||
Nav: []Node{{ID: "aego/math", Children: []Node{{ID: "aego/math.Clamp"}}}}},
|
||
{Group: Group{ID: "start", Title: "Getting started", Section: SectionStart}, Nav: []Node{{ID: "welcome"}}},
|
||
{Group: Group{ID: "script", Title: "Aego Script", Section: SectionGuides, Order: 20}, Nav: []Node{{ID: "language", Children: []Node{
|
||
{ID: "language/coroutines"},
|
||
{ID: "language/builtins", Children: []Node{{ID: "keyword/yield"}}},
|
||
}}}},
|
||
{Group: Group{ID: "components", Title: "Components", Section: SectionReference, Order: 110},
|
||
Nav: []Node{{ID: "module/script", Children: []Node{{ID: "engine.SpriteRenderer", Children: []Node{{ID: "engine.SpriteRenderer.Tint"}}}}}}},
|
||
{Group: Group{ID: "diagnostics", Title: "Diagnostics", Section: SectionReference, Order: 130}, Nav: []Node{{ID: "diagnostics", Children: []Node{{ID: "AES2050"}}}}},
|
||
{Group: Group{ID: "project", Title: "Project", Section: SectionReference, Order: 150}, Nav: []Node{{ID: "project", Children: []Node{{ID: "scripts/game/player"}}}}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func crawl(t *testing.T, dir string) map[string]string {
|
||
t.Helper()
|
||
pages := map[string]string{}
|
||
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||
if err != nil || d.IsDir() || filepath.Ext(p) != ".html" {
|
||
return err
|
||
}
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, _ := filepath.Rel(dir, p)
|
||
pages[filepath.ToSlash(rel)] = string(data)
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ids := map[string]map[string]bool{}
|
||
for p, body := range pages {
|
||
ids[p] = map[string]bool{}
|
||
for _, m := range idRe.FindAllStringSubmatch(body, -1) {
|
||
ids[p][html.UnescapeString(m[1])] = true
|
||
}
|
||
}
|
||
for p, body := range pages {
|
||
for _, m := range hrefRe.FindAllStringSubmatch(body, -1) {
|
||
href := html.UnescapeString(m[1])
|
||
if href == "#" {
|
||
t.Errorf("%s: placeholder link", p)
|
||
continue
|
||
}
|
||
if strings.Contains(href, "://") || strings.HasPrefix(href, "mailto:") || strings.HasPrefix(href, "data:") {
|
||
continue
|
||
}
|
||
target, frag, _ := strings.Cut(href, "#")
|
||
target, _, _ = strings.Cut(target, "?")
|
||
file := p
|
||
if target != "" {
|
||
file = path.Clean(path.Join(path.Dir(p), target))
|
||
if strings.HasSuffix(target, "/") {
|
||
file = path.Join(file, "index.html")
|
||
}
|
||
}
|
||
if strings.HasSuffix(file, ".css") || strings.HasSuffix(file, ".js") {
|
||
if _, err := os.Stat(filepath.Join(dir, file)); err != nil {
|
||
t.Errorf("%s: missing asset %s", p, href)
|
||
}
|
||
continue
|
||
}
|
||
got, ok := ids[file]
|
||
if !ok {
|
||
t.Errorf("%s: dead link %s", p, href)
|
||
continue
|
||
}
|
||
if frag != "" && !got[frag] {
|
||
t.Errorf("%s: missing anchor %s", p, href)
|
||
}
|
||
}
|
||
}
|
||
return pages
|
||
}
|
||
|
||
func build(t *testing.T, exp *Export, opt Options) (string, Report, map[string]string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
rep, err := Build(exp, dir, opt)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, b := range rep.Broken {
|
||
t.Errorf("broken reference %s", b)
|
||
}
|
||
pages := crawl(t, dir)
|
||
for _, e := range exp.Entries {
|
||
if e.Kind == KindModule {
|
||
if _, ok := pages[pagePath(&e)]; !ok {
|
||
t.Errorf("module %s has no page", e.ID)
|
||
}
|
||
}
|
||
}
|
||
return dir, rep, pages
|
||
}
|
||
|
||
func golden(t *testing.T, name, got string) {
|
||
t.Helper()
|
||
file := filepath.Join("testdata", name)
|
||
if *update {
|
||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(file, []byte(got), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return
|
||
}
|
||
want, err := os.ReadFile(file)
|
||
if err != nil {
|
||
t.Fatalf("%s: %v (run go test ./site -update and review it)", name, err)
|
||
}
|
||
if string(want) != got {
|
||
t.Errorf("%s changed, run go test ./site -update and review the diff:\n%s", name, got)
|
||
}
|
||
}
|
||
|
||
func TestFixtureSite(t *testing.T) {
|
||
_, _, pages := build(t, fixture(), Options{})
|
||
co := pages["language/coroutines.html"]
|
||
for _, want := range []string{
|
||
`href="../language/builtins.html#keyword/yield"`,
|
||
`href="../ref/aego/math.html#aego/math.Clamp"`,
|
||
`href="../diagnostics.html#AES2050"`,
|
||
`<span class="t-kw">package</span>`,
|
||
} {
|
||
if !strings.Contains(co, want) {
|
||
t.Errorf("coroutines page has no %s", want)
|
||
}
|
||
}
|
||
if !strings.Contains(pages["language/builtins.html"], `id="keyword/yield"`) {
|
||
t.Error("keyword is not a section of the builtins page")
|
||
}
|
||
if strings.Contains(co, `class="snippet"`) {
|
||
t.Error("run buttons without a playground")
|
||
}
|
||
if !strings.Contains(pages["ref/scripts/game/player.html"], "scripts/player/player.aes:3") {
|
||
t.Error("project script page has no source location")
|
||
}
|
||
}
|
||
|
||
func TestNavOutline(t *testing.T) {
|
||
_, rep, _ := build(t, fixture(), Options{})
|
||
if !strings.HasPrefix(rep.Outline, "Getting started\n") {
|
||
t.Fatalf("sections are out of order:\n%s", rep.Outline)
|
||
}
|
||
golden(t, "nav.golden", rep.Outline)
|
||
}
|
||
|
||
func TestShell(t *testing.T) {
|
||
_, _, pages := build(t, fixture(), Options{Repo: "https://git.example/aego", Version: "1.2.3", Commit: "abc1234"})
|
||
w := pages["welcome.html"]
|
||
for _, want := range []string{
|
||
`href="https://git.example/aego/src/branch/main/engine/res/docs/welcome.md#L1"`,
|
||
`href="https://git.example/aego/commit/abc1234"`,
|
||
`Aego 1.2.3`,
|
||
`<a href="#install">Install</a>`,
|
||
`class="next" href="language.html"`,
|
||
`data-group="start" open data-current="true"`,
|
||
`aria-current="page"`,
|
||
} {
|
||
if !strings.Contains(w, want) {
|
||
t.Errorf("welcome page has no %s", want)
|
||
}
|
||
}
|
||
co := pages["language/coroutines.html"]
|
||
if !strings.Contains(co, `<nav class="crumbs"><span>Guides</span><span class="sep">/</span><span>Aego Script</span><span class="sep">/</span><a href="../language.html">Aego Script</a></nav>`) {
|
||
t.Error("breadcrumbs are wrong on the coroutines page")
|
||
}
|
||
if !strings.Contains(pages["index.html"], `class="button primary" href="welcome.html"`) {
|
||
t.Error("landing page has no start button")
|
||
}
|
||
}
|
||
|
||
func TestSearchAndLlms(t *testing.T) {
|
||
dir, _, _ := build(t, fixture(), Options{})
|
||
data, err := os.ReadFile(filepath.Join(dir, "search.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var items []searchItem
|
||
if err := json.Unmarshal(data, &items); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, it := range items {
|
||
if it.Section == "" || it.Group == "" {
|
||
t.Errorf("search item %s has no category", it.ID)
|
||
}
|
||
}
|
||
llms, err := os.ReadFile(filepath.Join(dir, "llms.txt"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, want := range []string{"## Guides", "### Aego Script", "- [Aego Script](language.html): The language."} {
|
||
if !strings.Contains(string(llms), want) {
|
||
t.Errorf("llms.txt has no %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRunButtons(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if _, err := Build(fixture(), dir, Options{Playground: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(filepath.Join(dir, "language", "coroutines.html"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(string(data), `data-example="language/coroutines/1"`) {
|
||
t.Fatal("run block has no example id matching the playground")
|
||
}
|
||
}
|
||
|
||
func TestRealExport(t *testing.T) {
|
||
in := os.Getenv("AEGO_DOCS_EXPORT")
|
||
if in == "" {
|
||
t.Skip("AEGO_DOCS_EXPORT is not set")
|
||
}
|
||
exp, err := Load(in)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(exp.Groups) == 0 {
|
||
t.Fatal("the export has no groups, rebuild the aego CLI")
|
||
}
|
||
_, rep, _ := build(t, exp, Options{})
|
||
golden(t, "nav-real.golden", rep.Outline)
|
||
}
|
||
|
||
func TestComponentPage(t *testing.T) {
|
||
exp := fixture()
|
||
for i := range exp.Entries {
|
||
e := &exp.Entries[i]
|
||
switch e.ID {
|
||
case "engine.SpriteRenderer":
|
||
e.Component = &ComponentInfo{Category: "Rendering", Module: "Aego Script", Space: "2D", Requires: []string{"engine.Missing"},
|
||
Create: "Create → Rendering → Sprite", Events: []string{"aego/math.Clamp"}}
|
||
e.Related = []string{"language"}
|
||
case "engine.SpriteRenderer.Tint":
|
||
e.Field = &FieldInfo{Type: "Color", Default: "rgba(1, 1, 1, 1)", Range: "0 – 1", Notes: []string{"read-only"}}
|
||
}
|
||
}
|
||
_, _, pages := build(t, exp, Options{})
|
||
p := pages["ref/engine.SpriteRenderer.html"]
|
||
for _, want := range []string{
|
||
`<tr id="engine.SpriteRenderer.Tint">`,
|
||
`<code>rgba(1, 1, 1, 1)</code>`,
|
||
`<span class="note">read-only</span>`,
|
||
`Create → Rendering → Sprite`,
|
||
`<code>engine.Missing</code>`,
|
||
`<a class="xref" href="../language.html">Aego Script</a>`,
|
||
`href="../ref/aego/math.html#aego/math.Clamp"`,
|
||
`href="#engine.SpriteRenderer-fields">Fields</a>`,
|
||
} {
|
||
if !strings.Contains(p, want) {
|
||
t.Errorf("component page has no %s", want)
|
||
}
|
||
}
|
||
if strings.Contains(p, `<h2 id="engine.SpriteRenderer.Tint"`) {
|
||
t.Error("a field is rendered both in the table and as a section")
|
||
}
|
||
}
|
||
|
||
func TestTryBlock(t *testing.T) {
|
||
exp := fixture()
|
||
for i := range exp.Entries {
|
||
if exp.Entries[i].ID == "engine.SpriteRenderer" {
|
||
exp.Entries[i].Try = "scene version=1 {\n node \"a\" name=\"<b>\" {}\n}\n"
|
||
exp.Entries[i].TrySelect = "engine.SpriteRenderer"
|
||
}
|
||
}
|
||
dir := t.TempDir()
|
||
if _, err := Build(exp, dir, Options{Playground: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(filepath.Join(dir, "ref", "engine.SpriteRenderer.html"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, want := range []string{`class="try"`, `data-select="engine.SpriteRenderer"`, `name="<b>"`, `class="try-start"`, `id="engine.SpriteRenderer-try"`} {
|
||
if !strings.Contains(string(data), want) {
|
||
t.Errorf("try block has no %s", want)
|
||
}
|
||
}
|
||
_, _, plain := build(t, exp, Options{})
|
||
if strings.Contains(plain["ref/engine.SpriteRenderer.html"], `class="try"`) {
|
||
t.Error("a try block without the docs runtime")
|
||
}
|
||
}
|