(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+268
View File
@@ -0,0 +1,268 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func roundtripRichBlock(t *testing.T, in RichBlock) {
t.Helper()
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
out, err := UnmarshalRichBlock(b)
if err != nil {
t.Fatalf("unmarshal %s: %v", b, err)
}
b2, err := json.Marshal(out)
if err != nil {
t.Fatalf("remarshal: %v", err)
}
if string(b) != string(b2) {
t.Fatalf("not stable:\n %s\n %s", b, b2)
}
}
func par(s string) RichBlockWrap { return RichBlockWrap{"paragraph", RichTextPlain(s)} }
func TestRichBlockRoundtrip(t *testing.T) {
cases := []RichBlock{
// wrap blocks
par("Hello, world"),
RichBlockWrap{"footer", RichTextPlain("© 2024")},
RichBlockWrap{"thinking", RichTextPlain("Let me reason step by step.")},
// heading
RichBlockSectionHeading{RichTextWrap{"bold", RichTextPlain("Chapter 1")}, 1},
RichBlockSectionHeading{RichTextPlain("smallest"), 6},
// preformatted
RichBlockPreformatted{RichTextPlain(`fmt.Println("hi")`), "go"},
RichBlockPreformatted{Text: RichTextPlain("no language")},
// quotations
RichBlockQuotation{[]RichBlock{par("To be or not to be")}, RichTextPlain("Shakespeare")},
RichBlockQuotation{Blocks: []RichBlock{par("anonymous"), par("second block")}},
RichBlockPullQuotation{Text: RichTextPlain("Pull me")},
RichBlockPullQuotation{RichTextPlain("Wisdom"), RichTextWrap{"italic", RichTextPlain("someone")}},
// list: label is the ready-made marker, numbering lives on the items
RichBlockList{
Items: []RichBlockListItem{
{Label: "c.", Blocks: []RichBlock{par("item 3")}, Value: 3, Type: "a"},
{Label: "vii.", Blocks: []RichBlock{par("item 7")}, Value: 7, Type: "i"},
},
},
RichBlockList{
Items: []RichBlockListItem{
{Label: "•", Blocks: []RichBlock{par("todo")}, HasCheckbox: true},
{Label: "•", Blocks: []RichBlock{par("done")}, HasCheckbox: true, IsChecked: true},
},
},
// collage and slideshow
RichBlockCollage{
Blocks: []RichBlock{RichBlockPhoto{Photo: []PhotoSize{{FileID: "abc123", Width: 100, Height: 100}}}},
Caption: &RichBlockCaption{Text: RichTextPlain("A photo")},
},
RichBlockSlideshow{
Blocks: []RichBlock{
RichBlockVideo{Video: Video{FileID: "vid1", Width: 640, Height: 480, Duration: 10}},
},
},
// details
RichBlockDetails{
Summary: RichTextPlain("Spoiler"),
Blocks: []RichBlock{par("Hidden content")},
},
RichBlockDetails{
Summary: RichTextWrap{"bold", RichTextPlain("Open details")},
Blocks: []RichBlock{RichBlockDivider{}, par("content")},
IsOpen: true,
},
// table: text cells, headers, spans, alignment, invisible cell
RichBlockTable{
Cells: [][]RichBlockTableCell{
{
{Text: RichTextPlain("Name"), IsHeader: true, Align: "center"},
{Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"},
},
{
{Text: RichTextPlain("Alice"), ColSpan: 2},
},
{
{}, // invisible cell
{Text: RichTextPlain("42"), RowSpan: 2},
},
},
IsBordered: true,
Caption: RichTextPlain("Results"),
},
// map
RichBlockMap{
Location: Location{Latitude: 55.7558, Longitude: 37.6173},
Zoom: 13, Width: 800, Height: 400,
Caption: &RichBlockCaption{Text: RichTextPlain("Moscow"), Credit: RichTextPlain("OpenStreetMap")},
},
// media
RichBlockPhoto{
Photo: []PhotoSize{{FileID: "p1", Width: 1280, Height: 720}},
HasSpoiler: true,
Caption: &RichBlockCaption{Text: RichTextPlain("A cat"), Credit: RichTextWrap{"italic", RichTextPlain("photographer")}},
},
RichBlockVideo{Video: Video{FileID: "v1", Width: 1920, Height: 1080, Duration: 30}, HasSpoiler: true},
RichBlockAudio{
Audio: Audio{FileID: "a1", Duration: 60},
Caption: &RichBlockCaption{Text: RichTextPlain("Podcast ep. 1")},
},
RichBlockAnimation{Animation: Animation{FileID: "g1", Width: 320, Height: 240, Duration: 2}},
RichBlockVoiceNote{VoiceNote: Voice{FileID: "vn1", Duration: 5}},
// leaves
RichBlockDivider{},
RichBlockMathematicalExpression{Expression: "E = mc^2"},
RichBlockAnchor{Name: "section-2"},
}
for _, c := range cases {
roundtripRichBlock(t, c)
}
}
func TestRichMessageRoundtrip(t *testing.T) {
for _, msg := range []RichMessage{
{
Blocks: []RichBlock{
RichBlockSectionHeading{RichTextPlain("Title"), 1},
RichBlockWrap{"paragraph", RichTextArray{RichTextPlain("Some "), RichTextWrap{"bold", RichTextPlain("bold")}, RichTextPlain(" text")}},
RichBlockDivider{},
RichBlockList{
Items: []RichBlockListItem{
{Label: "1.", Blocks: []RichBlock{par("First")}, Value: 1, Type: "1"},
{Label: "2.", Blocks: []RichBlock{par("Second")}, Value: 2, Type: "1"},
},
},
RichBlockPhoto{
Photo: []PhotoSize{{FileID: "img1", Width: 10, Height: 10}},
Caption: &RichBlockCaption{Text: RichTextPlain("Fig. 1")},
},
},
},
{
Blocks: []RichBlock{par("שלום")},
IsRTL: true,
},
} {
b, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out RichMessage
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
b2, err := json.Marshal(out)
if err != nil {
t.Fatalf("remarshal: %v", err)
}
if string(b) != string(b2) {
t.Fatalf("not stable:\n %s\n %s", b, b2)
}
}
}
func TestRichBlockTags(t *testing.T) {
// tags per spec: heading, pre, blockquote, pullquote
cases := map[string]RichBlock{
"heading": RichBlockSectionHeading{RichTextPlain("h"), 2},
"pre": RichBlockPreformatted{Text: RichTextPlain("x")},
"blockquote": RichBlockQuotation{Blocks: []RichBlock{par("q")}},
"pullquote": RichBlockPullQuotation{Text: RichTextPlain("p")},
}
for want, block := range cases {
b, _ := json.Marshal(block)
var m map[string]any
_ = json.Unmarshal(b, &m)
if m["type"] != want {
t.Fatalf("expected type %q, got %s", want, b)
}
}
}
func TestRichBlockOptionalFieldsOmitted(t *testing.T) {
// nil credit/caption and false flags must not appear in the JSON
for _, c := range []struct {
block RichBlock
bad []string
}{
{RichBlockQuotation{Blocks: []RichBlock{par("q")}}, []string{"credit"}},
{RichBlockPullQuotation{Text: RichTextPlain("p")}, []string{"credit"}},
{RichBlockPhoto{Photo: []PhotoSize{{FileID: "p"}}}, []string{"caption", "has_spoiler"}},
{RichBlockTable{Cells: [][]RichBlockTableCell{}}, []string{"caption", "is_bordered", "is_striped"}},
{RichBlockDetails{Summary: RichTextPlain("s")}, []string{"is_open"}},
} {
b, _ := json.Marshal(c.block)
for _, key := range c.bad {
if strings.Contains(string(b), `"`+key+`"`) {
t.Fatalf("%T: %q must be omitted: %s", c.block, key, b)
}
}
}
// same for RichMessage.is_rtl
b, _ := json.Marshal(RichMessage{Blocks: []RichBlock{par("x")}})
if strings.Contains(string(b), "is_rtl") {
t.Fatalf("is_rtl must be omitted: %s", b)
}
}
func TestRichBlockDividerHasNoContent(t *testing.T) {
b, _ := json.Marshal(RichBlockDivider{})
var m map[string]any
_ = json.Unmarshal(b, &m)
if len(m) != 1 {
t.Fatalf("divider must only have type field: %s", b)
}
if m["type"] != "divider" {
t.Fatalf("unexpected type: %s", b)
}
}
func TestRichBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) {
raw := []byte(`{"type":"future_tag","text":"hello"}`)
b, err := UnmarshalRichBlock(raw)
if err != nil {
t.Fatalf("forward-compat failed: %v", err)
}
w, ok := b.(RichBlockWrap)
if !ok || w.Tag != "future_tag" {
t.Fatalf("expected RichBlockWrap{future_tag}, got %T", b)
}
}
func TestRichBlockUnknownTypeWithoutTextIsError(t *testing.T) {
raw := []byte(`{"type":"mystery_leaf","value":42}`)
_, err := UnmarshalRichBlock(raw)
if err == nil {
t.Fatal("expected error for unknown type without text")
}
}
func TestUnmarshalRichBlockRejectsMalformedFields(t *testing.T) {
tests := []string{
`{"type":"heading","size":"large","text":"hello"}`,
`{"type":"blockquote","blocks":[],"credit":{"type":"date_time","text":"now","unix_time":"soon"}}`,
`{"type":"table","cells":[],"caption":{"type":"date_time","text":"now","unix_time":"soon"}}`,
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
if _, err := UnmarshalRichBlock([]byte(raw)); err == nil {
t.Fatal("expected malformed rich block to be rejected")
}
})
}
}