FILE / ScuroNeko/Laniakea

tgrich/build_test.go

Исходный файл и его история в репозитории.
FILE dev
Files
ScuroNeko 24040fe164
Golang lint / lint (push) Successful in 58s
Golang lint / lint (pull_request) Successful in 13m10s
(new): expand runtime APIs
(fix): harden concurrent lifecycle
(tests): add regression coverage
(doc): update v1.2 guidance
2026-08-20 11:08:45 +03:00

305 lines
12 KiB
Go

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 &lt; y</tg-math-block>"},
{"anchor", Anchor(`a"b`), `<a name="a&quot;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},
{"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 != "<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
}