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