FILE / ScuroNeko/Laniakea
tgapi/rich_utils_test.go
Исходный файл и его история в репозитории.
295 lines
9.1 KiB
Go
295 lines
9.1 KiB
Go
package tgapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalRichMessageStrict(t *testing.T) {
|
|
for _, raw := range []string{`null`, `{}`, `{"blocks":null}`, `{"blocks":{}}`} {
|
|
t.Run(raw, func(t *testing.T) {
|
|
if _, err := UnmarshalRichMessageStrict([]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)
|
|
}
|
|
}
|