Files
larksuite-cli/shortcuts/im/convert_lib/card_test.go
梁硕 83dfb068ad feat: open-source lark-cli — the official CLI for Lark/Feishu
Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf
2026-03-28 10:36:25 +08:00

342 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"strings"
"testing"
)
func newTestCardConverter(mode cardMode) *cardConverter {
return &cardConverter{
mode: mode,
attachment: cardObj{
"persons": cardObj{
"ou_person": cardObj{"content": "Alice"},
},
"at_users": cardObj{
"ou_at": cardObj{"content": "Bob", "user_id": "u_bob"},
},
"images": cardObj{
"img_1": cardObj{"token": "img_tok_1"},
},
},
}
}
func TestConvertCard(t *testing.T) {
rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}`
got := convertCard(rawCard)
want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
if got != want {
t.Fatalf("convertCard(json_card) = %q, want %q", got, want)
}
legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}`
gotLegacy := convertCard(legacy)
wantLegacy := "**Legacy Card**\nlegacy body"
if gotLegacy != wantLegacy {
t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy)
}
}
func TestCardUtilityFunctions(t *testing.T) {
if !allColumnsAreButtons([]string{"[Open]", "[More](https://example.com)"}) {
t.Fatal("allColumnsAreButtons() = false, want true")
}
if allColumnsAreButtons([]string{"plain text", "[Open]"}) {
t.Fatal("allColumnsAreButtons() = true, want false")
}
if got := cardEscapeAttr("a\\\"b\nc\rd\t"); got != "a\\\\\\\"b\\nc\\rd\\t" {
t.Fatalf("cardEscapeAttr() = %q", got)
}
if got := cardFormatMillisToISO8601("1710500000000"); got == "" {
t.Fatal("cardFormatMillisToISO8601() returned empty")
}
if got := cardNormalizeTimeFormat("1710500000"); got == "1710500000" {
t.Fatalf("cardNormalizeTimeFormat() did not normalize seconds: %q", got)
}
if got := cardNormalizeTimeFormat("2026-03-23"); got != "2026-03-23" {
t.Fatalf("cardNormalizeTimeFormat() = %q, want original value", got)
}
}
func TestCardConverterMethods(t *testing.T) {
c := newTestCardConverter(cardModeDetailed)
if got := c.convertLink(cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}); got != "[Spec](https://example.com)" {
t.Fatalf("convertLink() = %q", got)
}
if got := c.convertMarkdown(cardObj{"content": "**bold**"}); got != "**bold**" {
t.Fatalf("convertMarkdown() = %q", got)
}
if got := c.convertMarkdownV1(cardObj{"fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, cardObj{}); got != "fallback" {
t.Fatalf("convertMarkdownV1() = %q", got)
}
if got := c.convertDiv(cardObj{
"text": cardObj{"tag": "text", "property": cardObj{"content": "Title"}, "text_size": "notation"},
"fields": []interface{}{cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Field 1"}}}},
"extra": cardObj{"tag": "text", "property": cardObj{"content": "Extra"}},
}, ""); got != "📝 Title\nField 1\nExtra" {
t.Fatalf("convertDiv() = %q", got)
}
if got := c.convertNote(cardObj{"elements": []interface{}{
cardObj{"tag": "text", "property": cardObj{"content": "Tip"}},
cardObj{"tag": "link", "property": cardObj{"content": "Doc", "url": cardObj{"url": "https://example.com/doc"}}},
}}); got != "📝 Tip [Doc](https://example.com/doc)" {
t.Fatalf("convertNote() = %q", got)
}
if got := c.convertEmoji(cardObj{"key": "OK"}); got != "👌" {
t.Fatalf("convertEmoji() = %q", got)
}
if got := c.convertLocalDatetime(cardObj{"milliseconds": "1710500000000"}); got == "" {
t.Fatal("convertLocalDatetime() returned empty")
}
if got := c.convertList(cardObj{"items": []interface{}{
cardObj{"level": float64(0), "type": "ul", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item1"}}}},
cardObj{"level": float64(1), "type": "ol", "order": float64(2), "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item2"}}}},
}}); got != "- item1\n 2. item2" {
t.Fatalf("convertList() = %q", got)
}
if got := c.convertBlockquote(cardObj{"content": "line1\nline2"}); got != "> line1\n> line2" {
t.Fatalf("convertBlockquote() = %q", got)
}
if got := c.convertCodeBlock(cardObj{"language": "go", "contents": []interface{}{
cardObj{"contents": []interface{}{cardObj{"content": "fmt.Println(1)"}}},
}}); got != "```go\nfmt.Println(1)```" {
t.Fatalf("convertCodeBlock() = %q", got)
}
if got := c.convertCodeSpan(cardObj{"content": "x := 1"}); got != "`x := 1`" {
t.Fatalf("convertCodeSpan() = %q", got)
}
if got := c.convertHeading(cardObj{"level": float64(2), "content": "Title"}); got != "## Title" {
t.Fatalf("convertHeading() = %q", got)
}
if got := c.convertFallbackText(cardObj{"text": cardObj{"content": "fallback"}}); got != "fallback" {
t.Fatalf("convertFallbackText() = %q", got)
}
if got := c.convertTextTag(cardObj{"text": cardObj{"content": "Tag"}}); got != "「Tag」" {
t.Fatalf("convertTextTag() = %q", got)
}
if got := c.convertNumberTag(cardObj{"text": cardObj{"content": "42"}, "url": cardObj{"url": "https://example.com/42"}}); got != "[42](https://example.com/42)" {
t.Fatalf("convertNumberTag() = %q", got)
}
if got := c.convertUnknown(cardObj{"title": cardObj{"content": "mystery"}}, "unknown"); got != "mystery" {
t.Fatalf("convertUnknown() = %q", got)
}
if got := c.convertColumnSet(cardObj{"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}},
cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}},
}}, 0); got != "[A] [B]" {
t.Fatalf("convertColumnSet() = %q", got)
}
if got := c.convertForm(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "form body"}}}}, ""); got != "<form>\nform body\n</form>" {
t.Fatalf("convertForm() = %q", got)
}
if got := c.convertCollapsiblePanel(cardObj{"expanded": true, "header": cardObj{"title": cardObj{"content": "More"}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "inside"}}}}, ""); got != "▼ More\n inside\n▲" {
t.Fatalf("convertCollapsiblePanel() = %q", got)
}
if got := c.convertInteractiveContainer(cardObj{"actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}}}, "cta_1"); got != "<clickable url=\"https://example.com\" id=\"cta_1\">\nClick here\n</clickable>" {
t.Fatalf("convertInteractiveContainer() = %q", got)
}
if got := c.convertRepeat(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "repeat"}}}}); got != "repeat" {
t.Fatalf("convertRepeat() = %q", got)
}
if got := c.convertActions(cardObj{"actions": []interface{}{
cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}},
cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}},
}}); got != "[One] [Two]" {
t.Fatalf("convertActions() = %q", got)
}
if got := c.convertOverflow(cardObj{"options": []interface{}{
cardObj{"text": cardObj{"content": "Edit"}},
cardObj{"text": cardObj{"content": "Delete"}},
}}); got != "⋮ Edit, Delete" {
t.Fatalf("convertOverflow() = %q", got)
}
if got := c.convertSelect(cardObj{
"options": []interface{}{
cardObj{"text": cardObj{"content": "Alice"}, "value": "a"},
cardObj{"text": cardObj{"content": "Bob"}, "value": "b"},
},
"selectedValues": []interface{}{"a"},
}, "select_person", true); got != "{✓Alice / Bob}(multi type:person)" {
t.Fatalf("convertSelect() = %q", got)
}
if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{🖼️ Image 1 / ✓🖼️ Image 2}" {
t.Fatalf("convertSelectImg() = %q", got)
}
if got := c.convertInput(cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}, ""); got != "Reason: Type..." {
t.Fatalf("convertInput() = %q", got)
}
if got := c.convertDatePicker(cardObj{"initialDate": "1710500000"}, "", "date"); got == "" || !strings.HasPrefix(got, "📅 ") {
t.Fatalf("convertDatePicker(date) = %q", got)
}
if got := c.convertChecker(cardObj{"checked": true, "text": cardObj{"content": "Done"}}, "chk_1"); got != "[x] Done(id:chk_1)" {
t.Fatalf("convertChecker() = %q", got)
}
if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "🖼️ Poster(img_token:img_tok_1)" {
t.Fatalf("convertImage() = %q", got)
}
if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "🖼️ 2 image(s)(keys:img_1,img_2)" {
t.Fatalf("convertImgCombination() = %q", got)
}
if got := c.convertChart(cardObj{"chartSpec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": "month",
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "value": 10},
cardObj{"month": "Feb", "value": 20},
}},
}}, ""); got != "📊 SalesBar chart\nSummary: Jan:10, Feb:20" {
t.Fatalf("convertChart() = %q", got)
}
if got := c.convertAudio(cardObj{"fileID": "audio_1"}, ""); got != "🎵 Audio(key:audio_1)" {
t.Fatalf("convertAudio() = %q", got)
}
if got := c.convertVideo(cardObj{"videoID": "video_1"}, ""); got != "🎬 Video(key:video_1)" {
t.Fatalf("convertVideo() = %q", got)
}
if got := c.convertTable(cardObj{
"columns": []interface{}{
cardObj{"displayName": "Name", "name": "name"},
cardObj{"displayName": "Score", "name": "score"},
},
"rows": []interface{}{
cardObj{
"name": cardObj{"data": "Alice"},
"score": cardObj{"data": float64(95.5)},
},
},
}); got != "| Name | Score |\n|------|------|\n| Alice | 95.50 |" {
t.Fatalf("convertTable() = %q", got)
}
if got := c.extractTableCellValue([]interface{}{cardObj{"text": "Tag 1"}, cardObj{"text": "Tag 2"}}); got != "「Tag 1」 「Tag 2」" {
t.Fatalf("extractTableCellValue() = %q", got)
}
if got := c.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" {
t.Fatalf("convertPerson() = %q", got)
}
if got := c.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" {
t.Fatalf("convertPersonV1() = %q", got)
}
if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "u2"}}}); got != "@user(id:u1), @user(id:u2)" {
t.Fatalf("convertPersonList() = %q", got)
}
if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "👤(id:ou_person)" {
t.Fatalf("convertAvatar() = %q", got)
}
if got := c.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" {
t.Fatalf("convertAt() = %q", got)
}
if style := c.extractTextStyle(cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic", "strikethrough"}}}); !style.bold || !style.italic || !style.strikethrough {
t.Fatalf("extractTextStyle() = %#v", style)
}
if got := c.applyTextStyle("hello", cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic"}}}); got != "***hello***" {
t.Fatalf("applyTextStyle() = %q", got)
}
if got := (interactiveConverter{}).Convert(&ConvertContext{RawContent: `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"inside\"}}]}}"}`}); got != "<card>\ninside\n</card>" {
t.Fatalf("interactiveConverter.Convert() = %q", got)
}
}
func TestCardConverterExtractTextHelpers(t *testing.T) {
c := newTestCardConverter(cardModeDetailed)
if got := c.extractTextFromProperty(cardObj{
"i18nContent": cardObj{
"zh_cn": "你好",
"en_us": "hello",
},
}); got != "你好" {
t.Fatalf("extractTextFromProperty(i18n) = %q", got)
}
if got := c.extractTextFromProperty(cardObj{"content": "content-first"}); got != "content-first" {
t.Fatalf("extractTextFromProperty(content) = %q", got)
}
if got := c.extractTextFromProperty(cardObj{
"elements": []interface{}{
cardObj{"property": cardObj{"content": "A"}},
cardObj{"content": "B"},
123,
},
}); got != "AB" {
t.Fatalf("extractTextFromProperty(elements) = %q", got)
}
if got := c.extractTextFromProperty(cardObj{"text": "plain-text"}); got != "plain-text" {
t.Fatalf("extractTextFromProperty(text) = %q", got)
}
if got := c.extractTextContent(cardObj{"property": cardObj{"content": "wrapped"}}); got != "wrapped" {
t.Fatalf("extractTextContent(property) = %q", got)
}
if got := c.extractTextFromProperty(cardObj{}); got != "" {
t.Fatalf("extractTextFromProperty(empty) = %q, want empty", got)
}
}
func TestCardConverterDispatch(t *testing.T) {
c := newTestCardConverter(cardModeDetailed)
tests := []struct {
name string
elem cardObj
want string
contains string
}{
{name: "plain text", elem: cardObj{"tag": "plain_text", "property": cardObj{"content": "hello"}}, want: "hello"},
{name: "markdown", elem: cardObj{"tag": "markdown", "property": cardObj{"content": "**bold**"}}, want: "**bold**"},
{name: "markdown v1", elem: cardObj{"tag": "markdown_v1", "fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, want: "fallback"},
{name: "div", elem: cardObj{"tag": "div", "property": cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Body"}}}}, want: "Body"},
{name: "note", elem: cardObj{"tag": "note", "property": cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Tip"}}}}}, want: "📝 Tip"},
{name: "hr", elem: cardObj{"tag": "hr"}, want: "---"},
{name: "br", elem: cardObj{"tag": "br"}, want: "\n"},
{name: "column set", elem: cardObj{"tag": "column_set", "property": cardObj{"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}},
cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}},
}}}, want: "[A] [B]"},
{name: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "@Alice(open_id:ou_person)"},
{name: "at", elem: cardObj{"tag": "at", "property": cardObj{"userID": "ou_at"}}, want: "@Bob(user_id:u_bob)"},
{name: "at all", elem: cardObj{"tag": "at_all"}, want: "@everyone"},
{name: "actions", elem: cardObj{"tag": "actions", "property": cardObj{"actions": []interface{}{
cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}},
cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}},
}}}, want: "[One] [Two]"},
{name: "input", elem: cardObj{"tag": "input", "property": cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}}, want: "Reason: Type..."},
{name: "date", elem: cardObj{"tag": "date_picker", "property": cardObj{"initialDate": "1710500000"}}, contains: "📅 "},
{name: "checker", elem: cardObj{"tag": "checker", "id": "chk_1", "property": cardObj{"checked": true, "text": cardObj{"content": "Done"}}}, want: "[x] Done(id:chk_1)"},
{name: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "🖼️ Poster(img_token:img_tok_1)"},
{name: "interactive", elem: cardObj{"tag": "interactive_container", "id": "cta_1", "property": cardObj{
"actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}},
"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}},
}}, want: "<clickable url=\"https://example.com\" id=\"cta_1\">\nClick here\n</clickable>"},
{name: "text tag", elem: cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Tag"}}}, want: "「Tag」"},
{name: "link", elem: cardObj{"tag": "link", "property": cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}}, want: "[Spec](https://example.com)"},
{name: "emoji", elem: cardObj{"tag": "emoji", "property": cardObj{"key": "OK"}}, want: "👌"},
{name: "card header suppressed", elem: cardObj{"tag": "card_header"}, want: ""},
{name: "unknown", elem: cardObj{"tag": "mystery", "property": cardObj{"title": cardObj{"content": "mystery"}}}, want: "mystery"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.convertElement(tt.elem, 0)
if tt.contains != "" {
if !strings.Contains(got, tt.contains) {
t.Fatalf("convertElement(%s) = %q, want containing %q", tt.name, got, tt.contains)
}
return
}
if got != tt.want {
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}