Skip to content
quarry Artifact [ef605a0490]
Login

Artifact [ef605a0490]

Artifact ef605a0490bf8b58c289b467994af6619ee0677cd5a521704802e243618f2015:


package markup

import (
	"regexp"
	"strings"
	"testing"
)

// The token grammar is the single place chat, forum, ticket and landing
// text agree on what a reference looks like (RFC 0011 §14). Assert the
// parse, not the rendering: the rendering has its own test.
func TestParseRefGrammar(t *testing.T) {
	for _, tt := range []struct {
		in                    string
		repo, kind, id, canon string
		ok                    bool
	}{
		{in: "#412", kind: "chat", id: "412", canon: "#412", ok: true},
		{in: "sqlite:#412", repo: "sqlite", kind: "chat", id: "412", canon: "sqlite:#412", ok: true},
		{in: "[a91f3c2e91]", kind: "hash", id: "a91f3c2e91", canon: "[a91f3c2e91]", ok: true},
		{in: "sqlite:[a91f3c2e91]", repo: "sqlite", kind: "hash", id: "a91f3c2e91", canon: "sqlite:[a91f3c2e91]", ok: true},
		{in: "a91f3c2e91", kind: "hash", id: "a91f3c2e91", canon: "[a91f3c2e91]", ok: true},
		{in: "sqlite:a91f3c2e91", repo: "sqlite", kind: "hash", id: "a91f3c2e91", canon: "sqlite:[a91f3c2e91]", ok: true},
		{in: "@alice", kind: "person", id: "alice", canon: "@alice", ok: true},
		{in: "/r/sqlite/chat/412", repo: "sqlite", kind: "chat", id: "412", canon: "sqlite:#412", ok: true},
		{in: "https://q.example/r/sqlite/ticket/abc123", repo: "sqlite", kind: "ticket", id: "abc123", canon: "sqlite:ticket/abc123", ok: true},
		{in: "/r/demo/wiki/Home", repo: "demo", kind: "wiki", id: "Home", canon: "demo:wiki/Home", ok: true},
		{in: "/r/demo/checkin/deadbeef", repo: "demo", kind: "checkin", id: "deadbeef", canon: "demo:checkin/deadbeef", ok: true},
		{in: "/r/demo/landing/abc", repo: "demo", kind: "landing", id: "abc", canon: "demo:landing/abc", ok: true},
		{in: "/r/demo/forum/abc", repo: "demo", kind: "forum", id: "abc", canon: "demo:forum/abc", ok: true},
		// Not references.
		{in: "hello"},
		{in: "#"},
		{in: "#0"},
		{in: "/r/demo/timeline"},
		{in: "0xdead"},
	} {
		got, ok := ParseRef(tt.in)
		if ok != tt.ok {
			t.Fatalf("ParseRef(%q) ok = %v, want %v", tt.in, ok, tt.ok)
		}
		if !ok {
			continue
		}
		if got.Repo != tt.repo || got.Kind != tt.kind || got.ID != tt.id {
			t.Fatalf("ParseRef(%q) = %+v, want repo %q kind %q id %q", tt.in, got, tt.repo, tt.kind, tt.id)
		}
		if got.Canonical() != tt.canon {
			t.Fatalf("ParseRef(%q).Canonical() = %q, want %q", tt.in, got.Canonical(), tt.canon)
		}
	}
}

// A reference found in prose has to be found at its exact span so the
// splice can leave the surrounding sentence alone.
func TestScanRefsSpans(t *testing.T) {
	s := "see #412 and sqlite:[a91f3c2e91] plus /r/demo/ticket/xy9 done"
	got := scanRefs(s)
	if len(got) != 3 {
		t.Fatalf("scanRefs found %d refs in %q: %+v", len(got), s, got)
	}
	for i, want := range []string{"#412", "sqlite:[a91f3c2e91]", "/r/demo/ticket/xy9"} {
		if s[got[i].start:got[i].end] != want {
			t.Fatalf("ref %d spans %q, want %q", i, s[got[i].start:got[i].end], want)
		}
	}
}

// tokenRes is a Resolver that also speaks the reference grammar. It
// resolves "demo" and the current repo, and refuses "secret" — the
// cross-repo token a reader may not see.
type tokenRes struct{ fakeResolver }

func (tokenRes) ResolveToken(t RefToken) (string, bool) {
	if t.Repo == "secret" {
		return "", false
	}
	repo := t.Repo
	if repo == "" {
		repo = "demo"
	}
	switch t.Kind {
	case RefChat:
		return "/r/" + repo + "/chat/" + t.ID, true
	case RefHash:
		return "/r/" + repo + "/checkin/" + t.ID, true
	case RefTicket:
		return "/r/" + repo + "/ticket/" + t.ID, true
	}
	return "", false
}

func TestRenderMintsReferenceLinks(t *testing.T) {
	out, err := Render([]byte("see #412 and sqlite:[a91f3c2e91] here"), tokenRes{})
	if err != nil {
		t.Fatal(err)
	}
	got := string(out)
	for _, want := range []string{
		`class="ref"`,
		`data-ref="#412"`,
		`data-ref-kind="chat"`,
		`href="/r/demo/chat/412"`,
		`data-ref="sqlite:[a91f3c2e91]"`,
		`href="/r/sqlite/checkin/a91f3c2e91"`,
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("rendered body missing %q:\n%s", want, got)
		}
	}
}

// A token the reader may not resolve stays a sentence, never a dead
// link (RFC 0011 §14: "resolving only when the reader can see it").
func TestRenderLeavesUnresolvableTokensAsText(t *testing.T) {
	out, err := Render([]byte("see secret:#9 and nothingatall"), tokenRes{})
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(out), "data-ref") {
		t.Fatalf("an unresolvable token became a link:\n%s", out)
	}
}

// A pasted quarry URL is autolinked by goldmark before the resolver
// sees it, so the resolver has to recognise the anchor it already made
// rather than only the text it did not.
func TestRenderAnnotatesPastedQuarryURLs(t *testing.T) {
	out, err := Render([]byte("look at https://q.example/r/demo/ticket/xy9 now"), tokenRes{})
	if err != nil {
		t.Fatal(err)
	}
	got := string(out)
	if !strings.Contains(got, `data-ref="demo:ticket/xy9"`) {
		t.Fatalf("pasted URL not annotated:\n%s", got)
	}
}

// Whatever Canonical writes, ParseRef must read: data-ref carries the
// canonical spelling and hands it straight back as a `ref=` parameter.
func TestCanonicalRoundTrips(t *testing.T) {
	for _, in := range []string{
		"#412", "sqlite:#412", "[a91f3c2e91]", "sqlite:[a91f3c2e91]", "@alice",
		"/r/sqlite/ticket/xy9", "/r/demo/wiki/Home", "/r/demo/landing/ab12",
		"https://q.example/r/demo/checkin/deadbeef",
	} {
		tok, ok := ParseRef(in)
		if !ok {
			t.Fatalf("%q did not parse", in)
		}
		back, ok := ParseRef(tok.Canonical())
		if !ok {
			t.Fatalf("canonical %q of %q did not parse back", tok.Canonical(), in)
		}
		if back != tok {
			t.Fatalf("%q round-tripped to %+v, want %+v", in, back, tok)
		}
	}
}

// tokenRes reads chat as a message posted before ^N: a bare #N there
// is still a message, so the corpus those tests pin keeps its meaning.
func (tokenRes) CaretIsChat() bool { return true }
func (tokenRes) SeqIsChat() bool   { return true }

// proseRes is tokenRes on a surface that is not chat.
type proseRes struct{ tokenRes }

func (proseRes) CaretIsChat() bool { return false }
func (proseRes) SeqIsChat() bool   { return false }

// chatRes is tokenRes on a chat message posted under ^N (#309).
type chatRes struct{ tokenRes }

func (chatRes) SeqIsChat() bool { return false }

// plainRes speaks the grammar and says nothing about its surface.
type plainRes struct{ fakeResolver }

func (plainRes) ResolveToken(t RefToken) (string, bool) { return tokenRes{}.ResolveToken(t) }

// refKinds lists each minted reference's data-ref-kind and href, in order.
func refKinds(t *testing.T, src string, r Resolver) []string {
	t.Helper()
	out, err := Render([]byte(src), r)
	if err != nil {
		t.Fatal(err)
	}
	var got []string
	for _, m := range regexp.MustCompile(`href="([^"]*)"[^>]*data-ref-kind="([a-z]+)"|data-ref-kind="([a-z]+)"[^>]*href="([^"]*)"`).FindAllStringSubmatch(string(out), -1) {
		if m[1] != "" {
			got = append(got, m[2]+" "+m[1])
		} else {
			got = append(got, m[3]+" "+m[4])
		}
	}
	return got
}

// #N is a ticket's number on every surface, and ^N is a chat message
// (#309). A chat message posted before ^N keeps reading #N as the
// message it meant; "ticket #N" names the ticket everywhere.
func TestANumberReadsByItsSurface(t *testing.T) {
	cases := []struct {
		name string
		res  Resolver
		src  string
		want []string
	}{
		{"prose", proseRes{}, "see #12 today", []string{"ticket /r/demo/ticket/12"}},
		{"prose caret", proseRes{}, "see ^12 today", nil},
		{"no surface", plainRes{}, "see #12 and ^4", []string{"ticket /r/demo/ticket/12"}},
		{"chat", chatRes{}, "see #12, ^4 and ticket #7", []string{
			"ticket /r/demo/ticket/12", "chat /r/demo/chat/4", "ticket /r/demo/ticket/7"}},
		{"chat qualified", chatRes{}, "see sqlite:^4", []string{"chat /r/sqlite/chat/4"}},
		{"old chat", tokenRes{}, "see #12, ^4 and ticket #7", []string{
			"chat /r/demo/chat/12", "chat /r/demo/chat/4", "ticket /r/demo/ticket/7"}},
		{"chat url", proseRes{}, "see /r/demo/chat/9", []string{"chat /r/demo/chat/9"}},
	}
	for _, c := range cases {
		got := refKinds(t, c.src, c.res)
		if strings.Join(got, "|") != strings.Join(c.want, "|") {
			t.Errorf("FAIL: %s: %q gave %q, want %q", c.name, c.src, got, c.want)
		}
	}
	if tok, ok := ParseRef("Ticket #7"); !ok || tok.Kind != RefTicket || tok.ID != "7" {
		t.Fatalf("FAIL: ParseRef(Ticket #7) = %+v", tok)
	}
	if tok, ok := ParseRef("sqlite:^7"); !ok || tok.Kind != RefChat || tok.ID != "7" || tok.Repo != "sqlite" {
		t.Fatalf("FAIL: ParseRef(sqlite:^7) = %+v", tok)
	}
	t.Log("ok: #N is a ticket, ^N a message in chat, old chat keeps #N a message")
}

// ^N is not a reference after a word or a caret, inside a footnote's
// brackets, or inside code; and it carries the same data-ref a message
// reference always has, so the hover card asks the same question.
func TestACaretIsOnlyAReferenceWhereItIsWritten(t *testing.T) {
	for _, src := range []string{"2^10 is 1024", "^^4", "a[^4] b", "`^4`", "```\n^4\n```", "^0"} {
		if got := refKinds(t, src, chatRes{}); len(got) != 0 {
			t.Errorf("FAIL: %q minted %q", src, got)
		}
	}
	out, err := Render([]byte("thanks for ^1601"), chatRes{})
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(out), `data-ref="#1601"`) {
		t.Fatalf("FAIL: ^1601 data-ref:\n%s", out)
	}
	if got := RefsIn("^5 and #6", chatRes{}); len(got) != 2 || got[0].Kind != RefChat || got[1].Kind != RefTicket {
		t.Fatalf("FAIL: RefsIn on new chat = %+v", got)
	}
	if got := RefsIn("^5 and #6", tokenRes{}); len(got) != 2 || got[0].Kind != RefChat || got[1].Kind != RefChat {
		t.Fatalf("FAIL: RefsIn on old chat = %+v", got)
	}
	if got := RefsIn("^5 and #6", proseRes{}); len(got) != 1 || got[0].Kind != RefTicket {
		t.Fatalf("FAIL: RefsIn in prose = %+v", got)
	}
	t.Log("ok: ^N links only where it is written as one")
}