feat: add chat
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user