package markup
import (
"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)
}
}
}
// proseRes is tokenRes on a surface where a bare #N names a ticket.
type proseRes struct{ tokenRes }
func (proseRes) SeqIsTicket() bool { return true }
// #N is a ticket's number everywhere but chat, where it stays a message
// and "ticket #N" names the ticket (54c5175f).
func TestANumberReadsByItsSurface(t *testing.T) {
prose, err := Render([]byte("see #12 today"), proseRes{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(prose), `href="/r/demo/ticket/12"`) || !strings.Contains(string(prose), `data-ref-kind="ticket"`) {
t.Fatalf("FAIL: in prose #12 is not the ticket:\n%s", prose)
}
chat, err := Render([]byte("see #12, and ticket #7 too"), tokenRes{})
if err != nil {
t.Fatal(err)
}
got := string(chat)
if !strings.Contains(got, `href="/r/demo/chat/12"`) || !strings.Contains(got, `href="/r/demo/ticket/7"`) {
t.Fatalf("FAIL: in chat #12 must stay a message and ticket #7 name the ticket:\n%s", got)
}
if tok, ok := ParseRef("Ticket #7"); !ok || tok.Kind != RefTicket || tok.ID != "7" {
t.Fatalf("FAIL: ParseRef(Ticket #7) = %+v", tok)
}
t.Log("ok: #12 is a ticket in prose and a message in chat; ticket #7 is a ticket in both")
}