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

text

"}, {"heading", H2(Text("heading")), "

heading

"}, {"pre", CodeBlock(Text("code"), "go"), `
code
`}, {"footer", Footer(Text("footer")), ""}, {"divider", Hr(), "
"}, {"math", Math("x < y"), "x < y"}, {"anchor", Anchor(`a"b`), ``}, {"unordered list", Ul(NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()), ""}, {"blockquote", BlockQuoteWithCredit(credit, P(Text("quote"))), "

quote

author
"}, {"pullquote", PullQuoteWithCredit(Text("quote"), credit), ""}, {"table", NewTable(Row(CellWithText(Text("value")).Build())).SetCaption(&credit).Build(), "
author
value
"}, {"details", DetailsOpen(Text("summary"), P(Text("body"))), "
summary

body

"}, {"map", Map(tgapi.Location{Latitude: 41.9, Longitude: 12.5}, 14, 640, 320), ``}, } 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 != "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"}), ``, tgapi.InputMediaTypePhoto, "attach://photo"}, {"video", Video(tgapi.InputMedia{Media: "video-id"}), ``, tgapi.InputMediaTypeVideo, "video-id"}, {"animation", Animation(tgapi.InputMedia{Media: "animation-id"}), ``, tgapi.InputMediaTypeAnimation, "animation-id"}, {"audio", Audio(tgapi.InputMedia{Media: "audio-id"}), ``, tgapi.InputMediaTypeAudio, "audio-id"}, {"voice note", VoiceNote(tgapi.InputMedia{Media: "voice-id"}), ``, tgapi.InputMediaTypeVoiceNote, "voice-id"}, {"caption and spoiler", PhotoWithCaption(tgapi.InputMedia{Media: "photo-id", HasSpoiler: &spoiler}, caption), `
captioncredit
`, 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 := `` 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}, {"rich text arrays too deep", func() []tgapi.InputRichBlock { text := tgapi.RichText(Text("text")) for range maxRichDepth { text = tgapi.RichTextArray{text} } return []tgapi.InputRichBlock{P(text)} }, 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 != "

text

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