(new): rich message support
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestBuildHTMLBlocks(t *testing.T) {
|
||||
credit := Text("author")
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
want string
|
||||
}{
|
||||
{"paragraph", P(Bold(Text("text"))), "<p><b>text</b></p>"},
|
||||
{"heading", H2(Text("heading")), "<h2>heading</h2>"},
|
||||
{"pre", CodeBlock(Text("code"), "go"), `<pre><code class="language-go">code</code></pre>`},
|
||||
{"footer", Footer(Text("footer")), "<footer>footer</footer>"},
|
||||
{"divider", Hr(), "<hr/>"},
|
||||
{"math", Math("x < y"), "<tg-math-block>x < y</tg-math-block>"},
|
||||
{"anchor", Anchor(`a"b`), `<a name="a"b"></a>`},
|
||||
{"unordered list", Ul(NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()), "<ul>\n<li><input type=\"checkbox\" checked/><p>item</p></li></ul>"},
|
||||
{"blockquote", BlockQuoteWithCredit(credit, P(Text("quote"))), "<blockquote><p>quote</p><cite>author</cite></blockquote>"},
|
||||
{"pullquote", PullQuoteWithCredit(Text("quote"), credit), "<aside>quote<cite>author</cite></aside>"},
|
||||
{"table", NewTable(Row(CellWithText(Text("value")).Build())).SetCaption(&credit).Build(), "<table><caption>author</caption><tr><td>value</td></tr></table>"},
|
||||
{"details", DetailsOpen(Text("summary"), P(Text("body"))), "<details open><summary>summary</summary><p>body</p></details>"},
|
||||
{"map", Map(tgapi.Location{Latitude: 41.9, Longitude: 12.5}, 14, 640, 320), `<tg-map height="320" lat="41.9" long="12.5" width="640" zoom="14"/>`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message, err := BuildHTML(tt.block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != tt.want {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.want)
|
||||
}
|
||||
if message.SkipEntityDetection {
|
||||
t.Fatal("BuildHTML() must preserve Telegram's automatic entity detection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingBlockIsDraftOnly(t *testing.T) {
|
||||
block := Thinking(Text("Thinking…"))
|
||||
if _, err := BuildHTML(block); !errors.Is(err, ErrRichThinkingDraftOnly) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, ErrRichThinkingDraftOnly)
|
||||
}
|
||||
message, err := BuildDraftHTML(block)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDraftHTML() returned error: %v", err)
|
||||
}
|
||||
if message.HTML != "<tg-thinking>Thinking…</tg-thinking>" {
|
||||
t.Fatalf("BuildDraftHTML() HTML = %q", message.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLValidatesAutomaticallyDetectedEntities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valid tgapi.RichText
|
||||
invalid tgapi.RichText
|
||||
}{
|
||||
{name: "bank card", valid: BankCardNumber(Text("1234"), "1234"), invalid: BankCardNumber(Text("5678"), "1234")},
|
||||
{name: "mention", valid: Mention(Text("@alice"), "alice"), invalid: Mention(Text("Alice"), "alice")},
|
||||
{name: "hashtag", valid: Hashtag(Text("#go"), "go"), invalid: Hashtag(Text("Go"), "go")},
|
||||
{name: "cashtag", valid: Cashtag(Text("$TON"), "TON"), invalid: Cashtag(Text("Toncoin"), "TON")},
|
||||
{name: "bot command", valid: BotCommand(Text("/start"), "start"), invalid: BotCommand(Text("Start"), "start")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := BuildHTML(P(tt.valid)); err != nil {
|
||||
t.Fatalf("valid entity returned error: %v", err)
|
||||
}
|
||||
if _, err := BuildHTML(P(tt.invalid)); !errors.Is(err, ErrRichEntityMismatch) {
|
||||
t.Fatalf("invalid entity error = %v, want ErrRichEntityMismatch", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftHTMLRejectsDirectUpload(t *testing.T) {
|
||||
_, err := BuildDraftHTML(Photo(tgapi.InputMedia{
|
||||
Type: tgapi.InputMediaTypePhoto,
|
||||
Media: "attach://photo",
|
||||
}))
|
||||
if !errors.Is(err, tgapi.ErrRichMessageDraftUploadUnsupported) {
|
||||
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLMedia(t *testing.T) {
|
||||
spoiler := true
|
||||
caption := CaptionWithCredit(Text("caption"), Text("credit"))
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
wantHTML string
|
||||
wantType tgapi.InputMediaType
|
||||
wantMedia string
|
||||
}{
|
||||
{"multipart photo", Photo(tgapi.InputMedia{Media: "attach://photo"}), `<img src="tg://photo?id=media_1"/>`, tgapi.InputMediaTypePhoto, "attach://photo"},
|
||||
{"video", Video(tgapi.InputMedia{Media: "video-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeVideo, "video-id"},
|
||||
{"animation", Animation(tgapi.InputMedia{Media: "animation-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeAnimation, "animation-id"},
|
||||
{"audio", Audio(tgapi.InputMedia{Media: "audio-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeAudio, "audio-id"},
|
||||
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeVoiceNote, "voice-id"},
|
||||
{"caption and spoiler", PhotoWithCaption(tgapi.InputMedia{Media: "photo-id", HasSpoiler: &spoiler}, caption), `<figure><img src="tg://photo?id=media_1" tg-spoiler/><figcaption>caption<cite>credit</cite></figcaption></figure>`, tgapi.InputMediaTypePhoto, "photo-id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message, err := BuildHTML(tt.block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != tt.wantHTML {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.wantHTML)
|
||||
}
|
||||
if len(message.Media) != 1 {
|
||||
t.Fatalf("BuildHTML() media count = %d, want 1", len(message.Media))
|
||||
}
|
||||
media := message.Media[0]
|
||||
if media.ID != "media_1" || media.Media.Type != tt.wantType || media.Media.Media != tt.wantMedia {
|
||||
t.Fatalf("BuildHTML() media = %#v", media)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLNestedMediaUsesSharedIDs(t *testing.T) {
|
||||
message, err := BuildHTML(
|
||||
Collage(Photo(tgapi.InputMedia{Media: "photo"}), Video(tgapi.InputMedia{Media: "video"})),
|
||||
Audio(tgapi.InputMedia{Media: "audio"}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `<tg-collage><img src="tg://photo?id=media_1"/><video src="tg://video?id=media_2"></video></tg-collage><audio src="tg://audio?id=media_3"></audio>`
|
||||
if message.HTML != want {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, want)
|
||||
}
|
||||
if len(message.Media) != 3 {
|
||||
t.Fatalf("BuildHTML() media count = %d, want 3", len(message.Media))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLLimits(t *testing.T) {
|
||||
media := func() tgapi.InputRichBlock {
|
||||
return Photo(tgapi.InputMedia{Media: "photo"})
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
blocks func() []tgapi.InputRichBlock
|
||||
want error
|
||||
}{
|
||||
{"maximum UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars)))}
|
||||
}, nil},
|
||||
{"too many UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars+1)))}
|
||||
}, ErrRichTextTooLong},
|
||||
{"maximum blocks", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichBlocks)
|
||||
for i := range blocks {
|
||||
blocks[i] = Hr()
|
||||
}
|
||||
return blocks
|
||||
}, nil},
|
||||
{"too many blocks", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichBlocks+1)
|
||||
for i := range blocks {
|
||||
blocks[i] = Hr()
|
||||
}
|
||||
return blocks
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"list items count as blocks", func() []tgapi.InputRichBlock {
|
||||
items := make([]tgapi.InputRichBlockListItem, maxRichBlocks)
|
||||
return []tgapi.InputRichBlock{List(items...)}
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"table rows count as blocks", func() []tgapi.InputRichBlock {
|
||||
rows := make([][]tgapi.RichBlockTableCell, maxRichBlocks)
|
||||
return []tgapi.InputRichBlock{NewTable(rows...).Build()}
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"maximum media", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichMedia)
|
||||
for i := range blocks {
|
||||
blocks[i] = media()
|
||||
}
|
||||
return blocks
|
||||
}, nil},
|
||||
{"too many media", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichMedia+1)
|
||||
for i := range blocks {
|
||||
blocks[i] = media()
|
||||
}
|
||||
return blocks
|
||||
}, ErrRichTooManyMedia},
|
||||
{"maximum table width", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns)...)).Build()}
|
||||
}, nil},
|
||||
{"table too wide", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns + 1)...)).Build()}
|
||||
}, ErrRichTableTooWide},
|
||||
{"colspan counts toward width", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(Cell().SetSpan(maxTableColumns+1, 1).Build())).Build()}
|
||||
}, ErrRichTableTooWide},
|
||||
{"maximum combined nesting", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth-1))}
|
||||
}, nil},
|
||||
{"combined nesting too deep", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth))}
|
||||
}, ErrRichNestingTooDeep},
|
||||
{"maximum block nesting", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth-1)}
|
||||
}, nil},
|
||||
{"block nesting too deep", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth)}
|
||||
}, ErrRichNestingTooDeep},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := BuildHTML(tt.blocks()...)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLRejectsInvalidFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
want error
|
||||
}{
|
||||
{"mixed list", List(NewListItem().SetType(tgapi.InputRichBlockListItemTypeDecimal).Build(), NewListItem().Build()), ErrRichListItemMix},
|
||||
{"unordered value", List(NewListItem().SetValue(2).Build()), ErrRichInvalidListItem},
|
||||
{"invalid list type", List(NewListItem().SetType("x").Build()), ErrRichInvalidListItemType},
|
||||
{"checked without checkbox", List(NewListItem().SetChecked().Build()), ErrRichInvalidCheckbox},
|
||||
{"invalid block type", tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeFooter, Text: Text("text")}, ErrRichInvalidBlockType},
|
||||
{"heading too small", H(Text("heading"), 0), ErrRichInvalidHeading},
|
||||
{"heading too large", H(Text("heading"), 7), ErrRichInvalidHeading},
|
||||
{"media mismatch", tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: tgapi.InputMedia{Type: tgapi.InputMediaTypeVideo, Media: "video"}}, ErrRichInvalidMedia},
|
||||
{"empty media", Photo(tgapi.InputMedia{}), ErrRichInvalidMedia},
|
||||
{"map latitude", Map(tgapi.Location{Latitude: 91}, 0, 0, 0), ErrRichInvalidMap},
|
||||
{"map longitude", Map(tgapi.Location{Longitude: 181}, 0, 0, 0), ErrRichInvalidMap},
|
||||
{"map zoom", Map(tgapi.Location{}, 25, 0, 0), ErrRichInvalidMap},
|
||||
{"map total dimensions", Map(tgapi.Location{}, 0, 9000, 1001), ErrRichInvalidMap},
|
||||
{"map aspect ratio", Map(tgapi.Location{}, 0, 100, 4), ErrRichInvalidMap},
|
||||
{"negative colspan", NewTable(Row(Cell().SetSpan(-1, 0).Build())).Build(), ErrRichInvalidTableCell},
|
||||
{"invalid horizontal alignment", NewTable(Row(Cell().SetAlign("justify").Build())).Build(), ErrRichInvalidTableCell},
|
||||
{"invalid vertical alignment", NewTable(Row(Cell().SetVAlign("center").Build())).Build(), ErrRichInvalidTableCell},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := BuildHTML(tt.block)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML(t *testing.T) {
|
||||
message, err := ToHTML(P(Text("text")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != "<p>text</p>" {
|
||||
t.Fatalf("ToHTML() HTML = %q", message.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCells(n int) []tgapi.RichBlockTableCell {
|
||||
return make([]tgapi.RichBlockTableCell, n)
|
||||
}
|
||||
|
||||
func wrapBold(text tgapi.RichText, depth int) tgapi.RichText {
|
||||
for range depth {
|
||||
text = Bold(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func wrapDetails(block tgapi.InputRichBlock, depth int) tgapi.InputRichBlock {
|
||||
for range depth {
|
||||
block = Details(Text("summary"), block)
|
||||
}
|
||||
return block
|
||||
}
|
||||
Reference in New Issue
Block a user