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 "" } 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) } }