package doc
import (
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
func TestTitleFallbackChain(t *testing.T) {
arc := archiveOf(t, map[string]string{
"notes/from-frontmatter.md": "---\ntitle: From Frontmatter\n---\n\n# Ignored H1\n",
"notes/from-h1.md": "# From H1\n\nbody\n",
"notes/from-filename.md": "just prose, no heading\n",
"notes/fenced-h1.md": "```\n# Not A Heading\n```\n\ntext\n",
})
want := map[string]string{
"notes/from-frontmatter.md": "From Frontmatter",
"notes/from-h1.md": "From H1",
"notes/from-filename.md": "from-filename",
"notes/fenced-h1.md": "fenced-h1",
}
for path, title := range want {
if got := mustPage(t, arc, path).Title; got != title {
t.Errorf("%s title = %q, want %q", path, got, title)
}
}
}
func TestPageKindClassification(t *testing.T) {
arc := archiveOf(t, map[string]string{
// No marker at all — the file-name fallback.
"notes/index.md": "# Notes Index\n",
"notes/log.md": "# Notes Log\n",
// The explicit frontmatter markers.
"specs/catalogue.md": "---\ntitle: Catalogue\ntype: catalog\n---\n",
"specs/journal.md": "---\ntitle: Journal\ntype: log\n---\n",
"specs/ordinary.md": "---\ntitle: Ordinary\ntype: concept\n---\n",
})
want := map[string]PageKind{
"notes/index.md": KindCatalog,
"notes/log.md": KindLog,
"specs/catalogue.md": KindCatalog,
"specs/journal.md": KindLog,
"specs/ordinary.md": KindMarkdown,
}
for path, kind := range want {
p := mustPage(t, arc, path)
if p.Kind != kind {
t.Errorf("%s kind = %q, want %q", path, p.Kind, kind)
}
if SuppressesEdges(p) != (kind != KindMarkdown) {
t.Errorf("%s: SuppressesEdges = %v for kind %q", path, SuppressesEdges(p), kind)
}
}
}
func TestParentChainAndCycleGuard(t *testing.T) {
arc := archiveOf(t, map[string]string{
"notes/root.md": "---\ntitle: Root\n---\n",
"notes/mid.md": "---\ntitle: Mid\nparent: \"[[root]]\"\n---\n",
"notes/leaf.md": "---\ntitle: Leaf\nparent: \"[[mid]]\"\n---\n",
// A deliberate two-document cycle, plus one that parents itself.
"notes/cyc-a.md": "---\ntitle: A\nparent: \"[[cyc-b]]\"\n---\n",
"notes/cyc-b.md": "---\ntitle: B\nparent: \"[[cyc-a]]\"\n---\n",
"notes/self.md": "---\ntitle: Self\nparent: \"[[self]]\"\n---\n",
// A parent that does not exist.
"notes/orphan.md": "---\ntitle: Orphan\nparent: \"[[nowhere]]\"\n---\n",
})
leaf := mustPage(t, arc, "notes/leaf.md")
if got := strings.Join(leaf.Crumbs, " > "); got != "notes/root > notes/mid" {
t.Errorf("leaf crumbs = %q", got)
}
if leaf.ParentID != "notes/mid" {
t.Errorf("leaf parent = %q", leaf.ParentID)
}
if kids := arc.Children("notes/mid"); len(kids) != 1 || kids[0] != leaf {
t.Errorf("Children(notes/mid) = %v", kids)
}
for _, path := range []string{"notes/cyc-a.md", "notes/cyc-b.md", "notes/self.md", "notes/orphan.md"} {
p := mustPage(t, arc, path)
if p.ParentID != "" || len(p.Crumbs) != 0 {
t.Errorf("%s: cyclic/broken parent must detach, got parent=%q crumbs=%v",
path, p.ParentID, p.Crumbs)
}
}
if len(arc.Roots()) != len(arc.Pages)-2 { // mid and leaf have parents
t.Errorf("Roots() = %d of %d documents", len(arc.Roots()), len(arc.Pages))
}
}
func TestResolveWikilinks(t *testing.T) {
arc := archiveOf(t, map[string]string{
"index.md": "---\ntitle: Home\n---\n",
"specs/index.md": "# Specs Index\n",
"specs/lsm-tree.md": "---\ntitle: LSM Tree\naliases:\n - log-structured-merge\n---\n",
"reports/lsm-tree.md": "Clipped report.\n",
"specs/0007-store.md": spec("SPEC-0007", "Storage model", "body"),
})
cases := []struct {
name string
fromDir string
dest string
want string
missing bool
}{
{"bare stem prefers the shorter path", "specs", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false},
{"path-qualified hits the other section", "specs", "reports/lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false},
{"proximity: a report links its own section first", "reports", "lsm-tree", "/~bigbes/rfcs/reports/lsm-tree", false},
{"a bare name prefers the linking document's own folder", "specs", "index", "/~bigbes/rfcs/specs/index", false},
{"path-qualified index", "reports", "specs/index", "/~bigbes/rfcs/specs/index", false},
{"a bare stem from the root falls to the stem contest", "", "lsm-tree", "/~bigbes/rfcs/specs/lsm-tree", false},
{"document id resolves from anywhere", "reports", "SPEC-0007", "/~bigbes/rfcs/specs/0007-store", false},
{"alias resolves to the canonical document", "specs", "log-structured-merge", "/~bigbes/rfcs/specs/lsm-tree", false},
{"heading reference becomes an anchor", "specs", "lsm-tree#Core Components", "/~bigbes/rfcs/specs/lsm-tree#core-components", false},
{"block reference has no anchor", "specs", "lsm-tree#^abc123", "/~bigbes/rfcs/specs/lsm-tree", false},
{"trailing .md is stripped", "specs", "lsm-tree.md", "/~bigbes/rfcs/specs/lsm-tree", false},
{"a lowercase id is not the id", "specs", "spec-0007", "", true},
{"unknown target is missing", "specs", "no-such-document", "", true},
{"path-qualified miss does not fall back to the stem", "specs", "notes/lsm-tree", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := arc.Resolve(c.fromDir, c.dest)
if c.missing {
if !got.Missing {
t.Fatalf("Resolve(%q) = %+v, want Missing", c.dest, got)
}
if got.Href != c.dest {
t.Errorf("a missing target must be handed back as written, got %q", got.Href)
}
return
}
if got.Missing {
t.Fatalf("Resolve(%q) unexpectedly missing", c.dest)
}
if got.Href != c.want {
t.Errorf("Resolve(%q).Href = %q, want %q", c.dest, got.Href, c.want)
}
})
}
}
func TestResolveExternalAndAnchors(t *testing.T) {
arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"})
cases := []struct {
dest string
external bool
href string
}{
{"https://example.com/x", true, "https://example.com/x"},
{"mailto:a@b.c", true, "mailto:a@b.c"},
{"//cdn.example.com/x", true, "//cdn.example.com/x"},
{"#same-page", false, "#same-page"},
}
for _, c := range cases {
got := arc.Resolve("notes", c.dest)
if got.IsExternal != c.external || got.Href != c.href {
t.Errorf("Resolve(%q) = %+v, want href=%q external=%v", c.dest, got, c.href, c.external)
}
}
}
// Every href is scoped to the space, because a document only means anything
// inside one, and every segment is escaped because paths carry spaces and
// Cyrillic.
func TestHrefsAreSpaceScopedAndEscaped(t *testing.T) {
arc := archiveOf(t, map[string]string{"notes/тех долг.md": "# Debt\n"})
p := mustPage(t, arc, "notes/тех долг.md")
if got := arc.DocHref(p); got != "/~bigbes/rfcs/notes/%D1%82%D0%B5%D1%85%20%D0%B4%D0%BE%D0%BB%D0%B3" {
t.Errorf("DocHref = %q", got)
}
if got := FromPages(core.SpaceRef{}, "main", []*Page{p}, nil, nil).DocHref(p); !strings.HasPrefix(got, "/notes/") {
t.Errorf("an archive with no space must yield root-relative hrefs, got %q", got)
}
}
// The link graph is what Backlinks reads, and nothing but LinkPass writes it.
func TestLinkPassFillsLinksAndWordCount(t *testing.T) {
files := map[string]string{
"specs/storage.md": "---\nid: SPEC-0001\ntitle: Storage\n---\n\n# Storage\n\nfour words of prose\n",
"specs/review.md": "---\nid: SPEC-0002\ntitle: Review\n---\n\n" +
"Supersedes [[SPEC-0001]], and again [[SPEC-0001]].\n\n" +
"```\n[[SPEC-0009]]\n```\n",
"notes/root.md": "See [[SPEC-0001]] from a note.\n",
}
arc := archiveOf(t, files)
bodies := make(map[string][]byte, len(files))
for p, body := range files {
bodies[p] = []byte(body)
}
// Before the pass there is no graph at all — the state every caller of
// Backlinks was silently in.
if got := arc.Backlinks("SPEC-0001"); len(got) != 0 {
t.Fatalf("Backlinks before LinkPass = %v, want none", got)
}
if err := arc.LinkPass(NewRenderer(), bodies); err != nil {
t.Fatalf("LinkPass: %v", err)
}
review := mustPage(t, arc, "specs/review.md")
// Deduped, and the fenced [[SPEC-0009]] is code, not a link.
if len(review.Links) != 1 || review.Links[0] != "SPEC-0001" {
t.Errorf("review links = %v, want [SPEC-0001]", review.Links)
}
if mustPage(t, arc, "specs/storage.md").WordCount != 5 {
t.Errorf("storage word count = %d, want 5", mustPage(t, arc, "specs/storage.md").WordCount)
}
var backPaths []string
for _, p := range arc.Backlinks("SPEC-0001") {
backPaths = append(backPaths, p.Path)
}
if len(backPaths) != 2 || backPaths[0] != "notes/root.md" || backPaths[1] != "specs/review.md" {
t.Errorf("backlinks = %v, want notes/root.md and specs/review.md", backPaths)
}
}
// A page with no body is the archive and the bodies disagreeing, which cannot
// happen while both come off one tree walk. Reported, never skipped: a skip
// drops that document's outbound links and under-reports backlinks elsewhere.
func TestLinkPassRefusesAMissingBody(t *testing.T) {
arc := archiveOf(t, map[string]string{"notes/a.md": "# A\n"})
err := arc.LinkPass(NewRenderer(), map[string][]byte{})
if err == nil || !strings.Contains(err.Error(), "notes/a.md") {
t.Fatalf("LinkPass with no bodies = %v, want an error naming the document", err)
}
}
func TestBacklinksIgnoreCatalogsAndLogs(t *testing.T) {
arc := archiveOf(t, map[string]string{
"notes/a.md": "# A\n",
"notes/b.md": "# B\n",
"notes/index.md": "# Index\n",
})
mustPage(t, arc, "notes/b.md").Links = []string{"notes/a"}
mustPage(t, arc, "notes/index.md").Links = []string{"notes/a"}
back := arc.Backlinks("notes/a")
if len(back) != 1 || back[0].Path != "notes/b.md" {
t.Fatalf("Backlinks = %v, want only notes/b.md", back)
}
}