206 lines
6.2 KiB
Go
206 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
// utf16Slice extracts a span the way a JavaScript client would, so the tests
|
|
// assert the offsets are usable by the actual renderer rather than merely
|
|
// self-consistent.
|
|
func utf16Slice(s string, offset, length int) string {
|
|
u := utf16.Encode([]rune(s))
|
|
if offset < 0 || offset+length > len(u) {
|
|
return "<out of range>"
|
|
}
|
|
return string(utf16.Decode(u[offset : offset+length]))
|
|
}
|
|
|
|
func TestParseEntities_Markers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
wantBody string
|
|
wantSpan map[string]string // entity type -> the text it should cover
|
|
}{
|
|
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
|
|
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
|
|
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
|
|
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body, entities := ParseEntities(tc.raw)
|
|
if body != tc.wantBody {
|
|
t.Fatalf("body = %q, want %q", body, tc.wantBody)
|
|
}
|
|
for kind, want := range tc.wantSpan {
|
|
found := false
|
|
for _, e := range entities {
|
|
if e.Type == kind {
|
|
if got := utf16Slice(body, e.Offset, e.Length); got != want {
|
|
t.Errorf("%s covers %q, want %q", kind, got, want)
|
|
}
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no %s entity in %+v", kind, entities)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Offsets are UTF-16 code units because the client slices JS strings with them.
|
|
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
|
|
// rune-based offset would silently mis-highlight in exactly this app's content.
|
|
func TestParseEntities_UTF16Offsets(t *testing.T) {
|
|
tests := []struct{ name, raw, want string }{
|
|
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
|
|
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
|
|
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
|
|
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body, entities := ParseEntities(tc.raw)
|
|
if len(entities) == 0 {
|
|
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
|
|
}
|
|
e := entities[len(entities)-1]
|
|
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
|
|
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Text that merely contains marker characters must survive untouched.
|
|
func TestParseEntities_NotFormatting(t *testing.T) {
|
|
for _, raw := range []string{
|
|
"2*3*4", // mid-word asterisks
|
|
"a~b~c", // mid-word tildes
|
|
"* not bold", // space after opener
|
|
"unmatched *marker", // no closer
|
|
"**", // empty
|
|
"5 * 3 = 15", // spaced operators
|
|
} {
|
|
body, entities := ParseEntities(raw)
|
|
if body != raw {
|
|
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
|
|
}
|
|
for _, e := range entities {
|
|
if e.Type != EntityURL {
|
|
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseEntities_Nesting(t *testing.T) {
|
|
body, entities := ParseEntities("*bold with `code` inside*")
|
|
const wantBody = "bold with code inside"
|
|
if body != wantBody {
|
|
t.Fatalf("body = %q, want %q", body, wantBody)
|
|
}
|
|
var bold, code *Entity
|
|
for i := range entities {
|
|
switch entities[i].Type {
|
|
case EntityBold:
|
|
bold = &entities[i]
|
|
case EntityCode:
|
|
code = &entities[i]
|
|
}
|
|
}
|
|
if bold == nil || code == nil {
|
|
t.Fatalf("want both bold and code, got %+v", entities)
|
|
}
|
|
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
|
|
t.Errorf("bold covers %q, want the whole body", got)
|
|
}
|
|
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
|
|
t.Errorf("code covers %q, want %q", got, "code")
|
|
}
|
|
// Outer span must sort first so a renderer opens it before the nested one.
|
|
if entities[0].Type != EntityBold {
|
|
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestParseEntities_URLs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
wantText string
|
|
wantHref string
|
|
}{
|
|
{"https", "see https://prabhupadasays.org/bg/2/13 here",
|
|
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
|
|
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
|
|
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
|
|
{"trailing sentence punctuation excluded", "read https://example.org/a.",
|
|
"https://example.org/a", "https://example.org/a"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body, entities := ParseEntities(tc.raw)
|
|
var url *Entity
|
|
for i := range entities {
|
|
if entities[i].Type == EntityURL {
|
|
url = &entities[i]
|
|
}
|
|
}
|
|
if url == nil {
|
|
t.Fatalf("no url entity in %+v", entities)
|
|
}
|
|
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
|
|
t.Errorf("covers %q, want %q", got, tc.wantText)
|
|
}
|
|
if url.URL != tc.wantHref {
|
|
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A URL inside backticks is being shown, not linked.
|
|
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
|
|
_, entities := ParseEntities("run `curl https://example.org` first")
|
|
for _, e := range entities {
|
|
if e.Type == EntityURL {
|
|
t.Errorf("linked a URL inside a code span: %+v", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
|
|
// keeps scripture references from turning blue.
|
|
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
|
|
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
|
|
_, entities := ParseEntities(raw)
|
|
for _, e := range entities {
|
|
if e.Type == EntityURL {
|
|
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// entities is stored as jsonb; empty must round-trip as [] rather than null so
|
|
// the column default and the parser agree.
|
|
func TestParseEntities_JSONShape(t *testing.T) {
|
|
_, entities := ParseEntities("plain text")
|
|
if entities == nil {
|
|
entities = []Entity{}
|
|
}
|
|
b, err := json.Marshal(entities)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(b) != "[]" {
|
|
t.Errorf("marshalled %s, want []", b)
|
|
}
|
|
}
|