package doc
import (
"context"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
func spec(id, title, body string) string {
return "---\nid: " + id + "\ntitle: " + title + "\nstatus: draft\n---\n\n" + body + "\n"
}
// The archive keys on the frontmatter id when there is one. Paths move; ids do
// not, so this is what makes [[SPEC-0007]] survive a rename.
func TestScanKeysOnDocumentID(t *testing.T) {
arc := archiveOf(t, map[string]string{
"specs/0007-storage.md": spec("SPEC-0007", "Storage model", "See [[SPEC-0003]]."),
"specs/0003-old.md": spec("SPEC-0003", "Old model", "superseded"),
})
p := mustPage(t, arc, "specs/0007-storage.md")
if p.ID != "SPEC-0007" || p.DocID != "SPEC-0007" {
t.Fatalf("ID/DocID = %q/%q, want SPEC-0007", p.ID, p.DocID)
}
if p.Title != "Storage model" || p.Status != core.StatusDraft {
t.Errorf("title/status = %q/%q", p.Title, p.Status)
}
if p.Blob == "" {
t.Errorf("Blob (the render cache key) was not carried over from git")
}
if got, ok := arc.Page("SPEC-0007"); !ok || got != p {
t.Errorf("Page(SPEC-0007) did not return the document")
}
got := arc.Resolve("specs", "SPEC-0003")
if got.Missing || got.Href != "/~bigbes/rfcs/specs/0003-old" {
t.Errorf("Resolve(SPEC-0003) = %+v", got)
}
}
// A document with no usable id is still a document: it keys on its path, stays
// readable, and keeps its path occupied. Refusing to serve it would turn one
// cosmetic typo into an outage for the whole space.
func TestScanToleratesDocumentsWithoutAnID(t *testing.T) {
arc := archiveOf(t, map[string]string{
"notes/free-form.md": "# Free form\n\nNo frontmatter at all.\n",
"notes/broken.md": "---\ntitle: [unclosed\n---\n\n# Recovered Title\n",
"notes/lower.md": "---\nid: spec-0007\ntitle: Lowercase id\nstatus: draft\n---\n\nbody\n",
})
for path, want := range map[string]string{
"notes/free-form.md": "Free form",
"notes/broken.md": "Recovered Title",
"notes/lower.md": "Lowercase id",
} {
p := mustPage(t, arc, path)
if p.Title != want {
t.Errorf("%s title = %q, want %q", path, p.Title, want)
}
if p.DocID != "" {
t.Errorf("%s claimed DocID %q; a malformed id must not enter id resolution", path, p.DocID)
}
if p.ID != strings.TrimSuffix(path, core.DocExt) {
t.Errorf("%s ID = %q, want the path without %q", path, p.ID, core.DocExt)
}
}
}
// The design tolerates a duplicated id on the approved branch and refuses to
// resolve it, rather than letting one of the two documents win silently.
func TestScanRefusesToResolveADuplicatedID(t *testing.T) {
arc := archiveOf(t, map[string]string{
"specs/a.md": spec("SPEC-0007", "First claimant", "a"),
"specs/b.md": spec("SPEC-0007", "Second claimant", "b"),
})
for _, path := range []string{"specs/a.md", "specs/b.md"} {
p := mustPage(t, arc, path)
if p.DocID != "SPEC-0007" {
t.Errorf("%s: DocID = %q, want the id as authored", path, p.DocID)
}
if p.ID != strings.TrimSuffix(path, core.DocExt) {
t.Errorf("%s: ID = %q, want the path", path, p.ID)
}
}
if _, ok := arc.Page("SPEC-0007"); ok {
t.Errorf("a duplicated id must resolve to neither document")
}
if got := arc.Resolve("specs", "SPEC-0007"); !got.Missing {
t.Errorf("Resolve(SPEC-0007) = %+v, want Missing", got)
}
}
// The approved head, a pinned sha and a proposal branch are the same code path
// with a different revision. That is the whole reason the checkout was dropped.
func TestScanReadsAnyRevisionThroughOnePath(t *testing.T) {
ctx := context.Background()
repo := space(t)
first := commit(t, repo, 1, repo.ApprovedBranch(), map[string]string{
"specs/0007-storage.md": spec("SPEC-0007", "Storage model", "v1"),
})
pinned, err := repo.ResolveRev(ctx, first)
if err != nil {
t.Fatalf("ResolveRev(%q): %v", first, err)
}
second := commit(t, repo, 2, first, map[string]string{
"specs/0007-storage.md": spec("SPEC-0007", "Storage model, revised", "v2"),
"notes/aside.md": "# Aside\n",
})
at := func(rev string) *Archive {
t.Helper()
arc, err := Scan(ctx, repo, fxSpace, rev)
if err != nil {
t.Fatalf("Scan(%q): %v", rev, err)
}
return arc
}
head := at(pinned.String())
if len(head.Pages) != 1 {
t.Fatalf("pinned rev has %d documents, want 1", len(head.Pages))
}
if got := mustPage(t, head, "specs/0007-storage.md").Title; got != "Storage model" {
t.Errorf("pinned rev title = %q, want the revision as it was", got)
}
draft := at(second)
if len(draft.Pages) != 2 {
t.Fatalf("proposal branch has %d documents, want 2", len(draft.Pages))
}
if got := mustPage(t, draft, "specs/0007-storage.md").Title; got != "Storage model, revised" {
t.Errorf("proposal branch title = %q", got)
}
if draft.Rev != second {
t.Errorf("Rev = %q, want the revision the caller named", draft.Rev)
}
}
func TestScanReportsGitErrors(t *testing.T) {
repo := space(t)
if _, err := Scan(context.Background(), repo, fxSpace, "no-such-branch"); err == nil {
t.Fatal("Scan of an unknown revision must fail rather than return an empty archive")
}
}
// Scan sorts by path, so an archive is identical whatever order the tree walk
// yields — which is what makes the index reproducible.
func TestFromDocumentsIsPathOrdered(t *testing.T) {
docs := []gitx.Document{
{Path: "specs/z.md", Data: []byte(spec("SPEC-0002", "Z", "z"))},
{Path: "notes/a.md", Data: []byte(spec("SPEC-0001", "A", "a"))},
}
arc := FromDocuments(fxSpace, "main", docs)
if len(arc.Pages) != 2 || arc.Pages[0].Path != "notes/a.md" {
t.Fatalf("pages = %v", arc.Pages)
}
}
// gitx enumerates documents only, so a git-fed archive has no attachment index
// and says so by marking the link missing instead of inventing an href. An
// attachment index supplied through FromPages resolves as it always did.
func TestAttachmentsComeFromTheCallerNotFromTheTreeWalk(t *testing.T) {
arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"})
if len(arc.Assets()) != 0 {
t.Fatalf("Scan invented an attachment index: %v", arc.Assets())
}
if got := arc.Resolve("notes", "diagram.png"); !got.Missing {
t.Errorf("Resolve(diagram.png) = %+v, want Missing", got)
}
withAssets := FromPages(fxSpace, "main", arc.Pages, nil, map[string]string{
"diagram.png": "assets/diagram.png",
"assets/diagram.png": "assets/diagram.png",
})
got := withAssets.Resolve("notes", "diagram.png")
if got.Missing || got.Href != "/~bigbes/rfcs/assets/diagram.png" {
t.Errorf("Resolve(diagram.png) = %+v", got)
}
}
// The two halves this package merged — the archive and the renderer — meet
// here: a body rendered with the archive as its Resolver must link to the
// space's own hrefs, feed the link graph, and mark what did not resolve.
func TestRenderThroughTheArchive(t *testing.T) {
arc := archiveOf(t, map[string]string{
"specs/0007-storage.md": spec("SPEC-0007", "Storage model",
"Supersedes [[SPEC-0003]] and [[SPEC-0099]].\n\n## Trade-offs\n\nSee [[0003-old|the old one]]."),
"specs/0003-old.md": spec("SPEC-0003", "Old model", "superseded"),
})
p := mustPage(t, arc, "specs/0007-storage.md")
_, body := ParseFront([]byte(spec("SPEC-0007", "Storage model",
"Supersedes [[SPEC-0003]] and [[SPEC-0099]].\n\n## Trade-offs\n\nSee [[0003-old|the old one]].")))
res := NewRenderer().Render(body, "specs", arc)
p.Links = res.LinkedIDs
p.WordCount = res.WordCount
if !strings.Contains(res.HTML, `href="/~bigbes/rfcs/specs/0003-old"`) {
t.Errorf("resolved wikilink did not become a space href:\n%s", res.HTML)
}
if !strings.Contains(res.HTML, `class="wikilink-missing"`) {
t.Errorf("unresolved wikilink was not marked broken:\n%s", res.HTML)
}
if got := strings.Join(res.MissingWikilinks, ","); got != "SPEC-0099" {
t.Errorf("MissingWikilinks = %v", res.MissingWikilinks)
}
if got := strings.Join(res.LinkedIDs, ","); got != "SPEC-0003" {
t.Errorf("LinkedIDs = %v, want the id once, deduped across both spellings", res.LinkedIDs)
}
if len(res.Headings) != 1 || res.Headings[0].ID != "trade-offs" {
t.Errorf("headings = %+v", res.Headings)
}
back := arc.Backlinks("SPEC-0003")
if len(back) != 1 || back[0].ID != "SPEC-0007" {
t.Fatalf("Backlinks(SPEC-0003) = %v", back)
}
}