FILE / ScuroNeko/Laniakea

tgapi/rich_utils_test.go

Исходный файл и его история в репозитории.
FILE d78526242bc7aa184ce5e030b95d8afe2d1243c4
Files
Laniakea/tgapi/rich_utils_test.go
T
ScuroNeko d78526242b
Golang lint / lint (push) Failing after 1m37s
(new): support Bot API 10.3
(fix): finalize v2 contracts
(tests): cover v2 migration
(doc): prepare release guidance
2026-09-08 23:21:38 +03:00

373 lines
11 KiB
Go

package tgapi
import (
"encoding/json"
"errors"
"reflect"
"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 TestUnknownRichObjectsRoundTripLosslessly(t *testing.T) {
tests := []struct {
name string
raw string
parse func([]byte) (any, error)
}{
{
name: "text",
raw: `{"type":"future_text","text":"hello","metadata":{"flag":true},"items":[1,2]}`,
parse: func(data []byte) (any, error) {
return UnmarshalRichText(data)
},
},
{
name: "block",
raw: `{"type":"future_block","value":42,"metadata":{"flag":true},"items":[1,2]}`,
parse: func(data []byte) (any, error) {
return UnmarshalRichBlock(data)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := []byte(tt.raw)
value, err := tt.parse(input)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
for i := range input {
input[i] = ' '
}
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var got, want any
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(tt.raw), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unknown object changed: got %s want %s", encoded, tt.raw)
}
switch v := value.(type) {
case RichTextUnknown:
if len(v.Raw) == 0 {
t.Fatal("empty preserved rich text")
}
case RichBlockUnknown:
if len(v.Raw) == 0 {
t.Fatal("empty preserved rich block")
}
default:
t.Fatalf("unexpected fallback type %T", value)
}
})
}
}
func TestUnknownRichObjectsRoundTripInsideMessage(t *testing.T) {
raw := []byte(`{"blocks":[{"type":"future_block","metadata":{"version":2}},{"type":"paragraph","text":{"type":"future_text","payload":[1,2,3]}}],"is_rtl":true}`)
message, err := UnmarshalRichMessage(raw)
if err != nil {
t.Fatalf("UnmarshalRichMessage failed: %v", err)
}
if _, ok := message.Blocks[0].(RichBlockUnknown); !ok {
t.Fatalf("first block type = %T, want RichBlockUnknown", message.Blocks[0])
}
paragraph, ok := message.Blocks[1].(RichBlockWrap)
if !ok {
t.Fatalf("second block type = %T, want RichBlockWrap", message.Blocks[1])
}
if _, ok := paragraph.Text.(RichTextUnknown); !ok {
t.Fatalf("paragraph text type = %T, want RichTextUnknown", paragraph.Text)
}
encoded, err := json.Marshal(message)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var got, want any
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("message changed: got %s want %s", encoded, raw)
}
}
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")
}
})
}
}
func TestUnmarshalRichMessageStrict(t *testing.T) {
for _, raw := range []string{`null`, `{}`, `{"blocks":null}`, `{"blocks":{}}`} {
t.Run(raw, func(t *testing.T) {
for _, parse := range []func([]byte) (RichMessage, error){UnmarshalRichMessage, UnmarshalRichMessageStrict} {
if _, err := parse([]byte(raw)); err == nil {
t.Fatal("expected strict decoder error")
}
}
})
}
if _, err := UnmarshalRichMessageStrict([]byte(`{"blocks":[]}`)); err != nil {
t.Fatalf("strict decoder rejected an empty blocks array: %v", err)
}
}
func TestRichJSONStructuralLimits(t *testing.T) {
deep := strings.Repeat("[", maximumRichJSONDepth+1) + `"x"` + strings.Repeat("]", maximumRichJSONDepth+1)
if _, err := UnmarshalRichText([]byte(deep)); !errors.Is(err, ErrRichJSONDepth) {
t.Fatalf("expected ErrRichJSONDepth, got %v", err)
}
wide := "[" + strings.Repeat("0,", maximumRichJSONNodes) + "0]"
if _, err := UnmarshalRichText([]byte(wide)); !errors.Is(err, ErrRichJSONNodes) {
t.Fatalf("expected ErrRichJSONNodes, got %v", err)
}
}