From 4df120eba1e7c17f159c1c600aa2882e681bcd20 Mon Sep 17 00:00:00 2001 From: yanzhongyi Date: Tue, 26 May 2026 14:23:07 +0800 Subject: [PATCH] feat: complete card message format Change-Id: I422474ab6b7505e48ab5697793900df035be6e29 --- shortcuts/im/convert_lib/card.go | 419 +++++++--- shortcuts/im/convert_lib/card_test.go | 1011 ++++++++++++++++++++++++- 2 files changed, 1302 insertions(+), 128 deletions(-) diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go index a418565b5..e58bf4959 100644 --- a/shortcuts/im/convert_lib/card.go +++ b/shortcuts/im/convert_lib/card.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "math" + "regexp" "strconv" "strings" "time" @@ -196,8 +197,12 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string { header, _ := card["header"].(cardObj) title := "" + subtitle := "" + headerTags := "" if header != nil { title = c.extractHeaderTitle(header) + subtitle = c.extractHeaderSubtitle(header) + headerTags = c.extractHeaderTags(header) } bodyContent := "" @@ -206,13 +211,19 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string { } var sb strings.Builder - if title != "" { - sb.WriteString("\n") + if title != "" && subtitle != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title), cardEscapeAttr(subtitle))) + } else if title != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(title))) + } else if subtitle != "" { + sb.WriteString(fmt.Sprintf("\n", cardEscapeAttr(subtitle))) } else { sb.WriteString("\n") } + if headerTags != "" { + sb.WriteString(headerTags) + sb.WriteString("\n") + } if bodyContent != "" { sb.WriteString(bodyContent) sb.WriteString("\n") @@ -233,6 +244,49 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string { return "" } +// extractHeaderSubtitle returns the subtitle text of a card header, supporting both +// the property-wrapped and flat element formats. +func (c *cardConverter) extractHeaderSubtitle(header cardObj) string { + if prop, ok := header["property"].(cardObj); ok { + if subtitleElem, ok := prop["subtitle"]; ok { + return c.extractTextContent(subtitleElem) + } + } + if subtitleElem, ok := header["subtitle"]; ok { + return c.extractTextContent(subtitleElem) + } + return "" +} + +// extractHeaderTags returns a space-joined string of header tag labels from textTagList, +// supporting both property-wrapped and flat header formats. +func (c *cardConverter) extractHeaderTags(header cardObj) string { + var prop cardObj + if p, ok := header["property"].(cardObj); ok { + prop = p + } else { + prop = header + } + tagList, ok := prop["textTagList"].([]interface{}) + if !ok || len(tagList) == 0 { + return "" + } + var tags []string + for _, tag := range tagList { + tm, ok := tag.(cardObj) + if !ok { + continue + } + if text := c.convertElement(tm, 0); text != "" { + tags = append(tags, text) + } + } + if len(tags) == 0 { + return "" + } + return strings.Join(tags, " ") +} + func (c *cardConverter) convertBody(body cardObj) string { var elements []interface{} @@ -479,8 +533,11 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string { if textElem, ok := prop["text"].(cardObj); ok { if text := c.convertElement(textElem, 0); text != "" { - if textSize, _ := textElem["text_size"].(string); textSize == "notation" { - text = "๐Ÿ“ " + text + textProp := c.extractProperty(textElem) + if textStyle, ok := textProp["textStyle"].(cardObj); ok { + if size, _ := textStyle["size"].(string); size == "notation" { + text = "๐Ÿ“ " + text + } } results = append(results, text) } @@ -558,7 +615,14 @@ func (c *cardConverter) convertEmoji(prop cardObj) string { } func (c *cardConverter) convertLocalDatetime(prop cardObj) string { - if ms, ok := prop["milliseconds"].(string); ok && ms != "" { + var ms string + switch v := prop["milliseconds"].(type) { + case string: + ms = v + case float64: + ms = strconv.FormatInt(int64(v), 10) + } + if ms != "" { if formatted := cardFormatMillisToISO8601(ms); formatted != "" { return formatted } @@ -789,22 +853,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string { } } - shouldExpand := expanded || c.mode == cardModeDetailed - if shouldExpand { - var sb strings.Builder - sb.WriteString("โ–ผ " + title + "\n") - if elements, ok := prop["elements"].([]interface{}); ok { - content := c.convertElements(elements, 1) - for _, line := range strings.Split(content, "\n") { - if line != "" { - sb.WriteString(" " + line + "\n") - } + indicator := "โ–ถ" + if expanded { + indicator = "โ–ผ" + } + var sb strings.Builder + sb.WriteString(indicator + " " + title + "\n") + if elements, ok := prop["elements"].([]interface{}); ok { + content := c.convertElements(elements, 1) + for _, line := range strings.Split(content, "\n") { + if line != "" { + sb.WriteString(" " + line + "\n") } } - sb.WriteString("โ–ฒ") - return sb.String() } - return "โ–ถ " + title + sb.WriteString("โ–ฒ") + return sb.String() } func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string { @@ -852,10 +916,17 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { } disabled, _ := prop["disabled"].(bool) - if disabled && c.mode == cardModeConcise { - return fmt.Sprintf("[%s โœ—]", buttonText) + if disabled { + result := fmt.Sprintf("[%s โœ—]", buttonText) + if tips, ok := prop["disabledTips"].(cardObj); ok { + if tipsText := c.extractTextContent(tips); tipsText != "" { + result += fmt.Sprintf("(tips:\"%s\")", tipsText) + } + } + return result } + result := fmt.Sprintf("[%s]", buttonText) if actions, ok := prop["actions"].([]interface{}); ok { for _, action := range actions { am, ok := action.(cardObj) @@ -865,24 +936,32 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { if am["type"] == "open_url" { if ad, ok := am["action"].(cardObj); ok { if urlStr, ok := ad["url"].(string); ok && urlStr != "" { - return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + break } } } } } - if disabled && c.mode == cardModeDetailed { - result := fmt.Sprintf("[%s โœ—]", buttonText) - if tips, ok := prop["disabledTips"].(cardObj); ok { - if tipsText := c.extractTextContent(tips); tipsText != "" { - result += fmt.Sprintf("(tips:\"%s\")", tipsText) + if confirmObj, ok := prop["confirm"].(cardObj); ok { + var parts []string + if titleElem, ok := confirmObj["title"]; ok { + if t := c.extractTextContent(titleElem); t != "" { + parts = append(parts, t) } } - return result + if textElem, ok := confirmObj["text"]; ok { + if t := c.extractTextContent(textElem); t != "" { + parts = append(parts, t) + } + } + if len(parts) > 0 { + result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": ")) + } } - return fmt.Sprintf("[%s]", buttonText) + return result } func (c *cardConverter) convertActions(prop cardObj) string { @@ -914,11 +993,33 @@ func (c *cardConverter) convertOverflow(prop cardObj) string { if !ok { continue } + text := "" if textElem, ok := om["text"].(cardObj); ok { - if text := c.extractTextContent(textElem); text != "" { - optTexts = append(optTexts, text) + text = c.extractTextContent(textElem) + } + if text == "" { + continue + } + urlStr := "" + if actions, ok := om["actions"].([]interface{}); ok { + for _, a := range actions { + am, ok := a.(cardObj) + if !ok { + continue + } + if am["type"] == "open_url" { + if ad, ok := am["action"].(cardObj); ok { + urlStr, _ = ad["url"].(string) + } + } } } + if urlStr != "" { + text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr) + } else if value, _ := om["value"].(string); value != "" { + text += "(" + value + ")" + } + optTexts = append(optTexts, text) } return "โ‹ฎ " + strings.Join(optTexts, ", ") } @@ -958,17 +1059,20 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str if !ok { continue } + value, _ := om["value"].(string) optText := "" if textElem, ok := om["text"].(cardObj); ok { optText = c.extractTextContent(textElem) } if optText == "" { - optText, _ = om["value"].(string) + optText = c.lookupOptionUserName(value) + } + if optText == "" { + optText = value } if optText == "" { continue } - value, _ := om["value"].(string) if selectedValues[value] { optText = "โœ“" + optText hasSelected = true @@ -989,17 +1093,15 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str } result := "{" + strings.Join(optionTexts, " / ") + "}" - if c.mode == cardModeDetailed { - var attrs []string - if isMulti { - attrs = append(attrs, "multi") - } - if strings.Contains(id, "person") { - attrs = append(attrs, "type:person") - } - if len(attrs) > 0 { - result += "(" + strings.Join(attrs, " ") + ")" - } + var attrs []string + if isMulti { + attrs = append(attrs, "multi") + } + if c.mode == cardModeDetailed && strings.Contains(id, "person") { + attrs = append(attrs, "type:person") + } + if len(attrs) > 0 { + result += "(" + strings.Join(attrs, " ") + ")" } return result } @@ -1025,6 +1127,17 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string { } value, _ := om["value"].(string) text := fmt.Sprintf("๐Ÿ–ผ๏ธ Image %d", i+1) + if value != "" { + text += "(" + value + ")" + } + if imageID, ok := om["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + text += "(img_key:" + originKey + ")" + } else if imgToken != "" { + text += "(img_token:" + imgToken + ")" + } + } if selectedValues[value] { text = "โœ“" + text } @@ -1127,13 +1240,14 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string { } result := "๐Ÿ–ผ๏ธ " + alt - if c.mode == cardModeDetailed { - if imageID, ok := prop["imageID"].(string); ok && imageID != "" { - if token := c.getImageToken(imageID); token != "" { - result += "(img_token:" + token + ")" - } else { - result += "(img_key:" + imageID + ")" - } + if imageID, ok := prop["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + result += "(img_key:" + originKey + ")" + } else if imgToken != "" { + result += "(img_token:" + imgToken + ")" + } else { + result += "(img_key:" + imageID + ")" } } return result @@ -1145,20 +1259,25 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string { return "" } result := fmt.Sprintf("๐Ÿ–ผ๏ธ %d image(s)", len(imgList)) - if c.mode == cardModeDetailed { - var keys []string - for _, img := range imgList { - im, ok := img.(cardObj) - if !ok { - continue - } - if imageID, ok := im["imageID"].(string); ok && imageID != "" { + var keys []string + for _, img := range imgList { + im, ok := img.(cardObj) + if !ok { + continue + } + if imageID, ok := im["imageID"].(string); ok && imageID != "" { + originKey, imgToken := c.getImageKeyAndToken(imageID) + if originKey != "" { + keys = append(keys, originKey) + } else if imgToken != "" { + keys = append(keys, imgToken) + } else { keys = append(keys, imageID) } } - if len(keys) > 0 { - result += "(keys:" + strings.Join(keys, ",") + ")" - } + } + if len(keys) > 0 { + result += "(keys:" + strings.Join(keys, ",") + ")" } return result } @@ -1176,7 +1295,11 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string { if ct, ok := chartSpec["type"].(string); ok && ct != "" { chartType = ct if typeName, ok := cardChartTypeNames[ct]; ok { - title += typeName + if title != "Chart" { + title += " (" + typeName + ")" + } else { + title = typeName + } } } } @@ -1194,12 +1317,25 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri if !ok { return "" } - dataObj, ok := chartSpec["data"].(cardObj) - if !ok { - return "" + + // VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]). + // Older/object format: data is a map with a "values" key directly. + var values []interface{} + switch d := chartSpec["data"].(type) { + case cardObj: + if v, ok := d["values"].([]interface{}); ok { + values = v + } + case []interface{}: + for _, series := range d { + if sm, ok := series.(cardObj); ok { + if v, ok := sm["values"].([]interface{}); ok { + values = append(values, v...) + } + } + } } - values, ok := dataObj["values"].([]interface{}) - if !ok || len(values) == 0 { + if len(values) == 0 { return "" } @@ -1244,28 +1380,24 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri func (c *cardConverter) convertAudio(prop cardObj, _ string) string { result := "๐ŸŽต Audio" - if c.mode == cardModeDetailed { - fileID, _ := prop["fileID"].(string) - if fileID == "" { - fileID, _ = prop["audioID"].(string) - } - if fileID != "" { - result += "(key:" + fileID + ")" - } + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["audioID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" } return result } func (c *cardConverter) convertVideo(prop cardObj, _ string) string { result := "๐ŸŽฌ Video" - if c.mode == cardModeDetailed { - fileID, _ := prop["fileID"].(string) - if fileID == "" { - fileID, _ = prop["videoID"].(string) - } - if fileID != "" { - result += "(key:" + fileID + ")" - } + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["videoID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" } return result } @@ -1323,9 +1455,14 @@ func (c *cardConverter) convertTable(prop cardObj) string { func (c *cardConverter) extractTableCellValue(data interface{}) string { switch v := data.(type) { case string: + // Lark API serialises array-type cell data as a Go-format string like + // "[map[text:VIP] map[text:Premium]]". Detect and extract text values. + if texts := goMapArrayTexts(v); len(texts) > 0 { + return strings.Join(texts, ", ") + } return v case float64: - return strconv.FormatFloat(v, 'f', 2, 64) + return strconv.FormatFloat(v, 'f', -1, 64) case []interface{}: var texts []string for _, item := range v { @@ -1346,6 +1483,47 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string { } } +// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon). +var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`) + +// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string, +// e.g. "[map[text:VIP] map[text:Premium]]" โ†’ ["VIP", "Premium"]. +// Values may contain spaces; they are delimited by the next map key or by "]". +// Returns nil if the string doesn't look like this format. +func goMapArrayTexts(s string) []string { + if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") { + return nil + } + const key = "text:" + var texts []string + rest := s + for { + idx := strings.Index(rest, key) + if idx < 0 { + break + } + after := rest[idx+len(key):] + bracketEnd := strings.Index(after, "]") + nextKey := goMapNextKey.FindStringIndex(after) + var end int + if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) { + end = nextKey[0] + } else if bracketEnd >= 0 { + end = bracketEnd + } else { + if after != "" { + texts = append(texts, after) + } + break + } + if val := after[:end]; val != "" { + texts = append(texts, val) + } + rest = after[end:] + } + return texts +} + func (c *cardConverter) convertPerson(prop cardObj, _ string) string { userID, _ := prop["userID"].(string) if userID == "" { @@ -1359,14 +1537,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string { } if personName != "" { if c.mode == cardModeDetailed { - return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + return fmt.Sprintf("%s(open_id:%s)", personName, userID) } - return "@" + personName + return personName } if c.mode == cardModeDetailed { - return fmt.Sprintf("@user(open_id:%s)", userID) + return fmt.Sprintf("user(open_id:%s)", userID) } - return "@" + userID + return userID } // convertPersonV1 handles the v1 card schema person element. @@ -1382,14 +1560,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string { personName := c.lookupPersonName(userID) if personName != "" { if c.mode == cardModeDetailed { - return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + return fmt.Sprintf("%s(open_id:%s)", personName, userID) } - return "@" + personName + return personName } if c.mode == cardModeDetailed { - return fmt.Sprintf("@user(open_id:%s)", userID) + return fmt.Sprintf("user(open_id:%s)", userID) } - return "@" + userID + return userID } func (c *cardConverter) convertPersonList(prop cardObj) string { @@ -1404,10 +1582,21 @@ func (c *cardConverter) convertPersonList(prop cardObj) string { continue } personID, _ := pm["id"].(string) - if c.mode == cardModeDetailed && personID != "" { - names = append(names, fmt.Sprintf("@user(id:%s)", personID)) + personName := c.lookupPersonName(personID) + if personName != "" { + if c.mode == cardModeDetailed { + names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID)) + } else { + names = append(names, personName) + } + } else if personID != "" { + if c.mode == cardModeDetailed { + names = append(names, fmt.Sprintf("user(id:%s)", personID)) + } else { + names = append(names, personID) + } } else { - names = append(names, "@user") + names = append(names, "user") } } return strings.Join(names, ", ") @@ -1415,8 +1604,15 @@ func (c *cardConverter) convertPersonList(prop cardObj) string { func (c *cardConverter) convertAvatar(prop cardObj, _ string) string { userID, _ := prop["userID"].(string) + personName := c.lookupPersonName(userID) + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("๐Ÿ‘ค %s(open_id:%s)", personName, userID) + } + return "๐Ÿ‘ค " + personName + } result := "๐Ÿ‘ค" - if c.mode == cardModeDetailed && userID != "" { + if userID != "" { result += "(id:" + userID + ")" } return result @@ -1497,20 +1693,37 @@ func (c *cardConverter) lookupPersonName(userID string) string { return "" } -func (c *cardConverter) getImageToken(imageID string) string { +// lookupOptionUserName resolves a user display name from the attachment's option_users map, +// used for person-selector option labels. +func (c *cardConverter) lookupOptionUserName(userID string) string { if c.attachment == nil { return "" } - if images, ok := c.attachment["images"].(cardObj); ok { - if imageInfo, ok := images[imageID].(cardObj); ok { - if token, ok := imageInfo["token"].(string); ok { - return token + if optUsers, ok := c.attachment["option_users"].(cardObj); ok { + if userInfo, ok := optUsers[userID].(cardObj); ok { + if content, ok := userInfo["content"].(string); ok { + return content } } } return "" } +// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map. +// origin_key takes priority over token as the display-ready image reference. +func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) { + if c.attachment == nil { + return "", "" + } + if images, ok := c.attachment["images"].(cardObj); ok { + if imageInfo, ok := images[imageID].(cardObj); ok { + originKey, _ = imageInfo["origin_key"].(string) + token, _ = imageInfo["token"].(string) + } + } + return originKey, token +} + type cardTextStyle struct { bold bool italic bool diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go index 49f63644b..1c25486ea 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/shortcuts/im/convert_lib/card_test.go @@ -19,7 +19,11 @@ func newTestCardConverter(mode cardMode) *cardConverter { "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, }, "images": cardObj{ - "img_1": cardObj{"token": "img_tok_1"}, + "img_1": cardObj{"token": "img_tok_1", "origin_key": "img_v3_test_key1"}, + }, + "option_users": cardObj{ + "opt_alice": cardObj{"content": "Alice"}, + "opt_bob": cardObj{"content": "Bob"}, }, }, } @@ -39,6 +43,12 @@ func TestConvertCard(t *testing.T) { if gotLegacy != wantLegacy { t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) } + + // C008 root cause: json_attachment as object (not string) โ€” persons resolved via attachment + withObjAttachment := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Title\"}},\"body\":{\"elements\":[{\"tag\":\"person\",\"property\":{\"userID\":\"ou_1\"}}]}}","json_attachment":{"persons":{"ou_1":{"content":"Alice"}}}}` + if got := convertCard(withObjAttachment, nil); !strings.Contains(got, "Alice") { + t.Fatalf("convertCard(json_attachment object) = %q, want person name resolved", got) + } } func TestCardUtilityFunctions(t *testing.T) { @@ -75,7 +85,7 @@ func TestCardConverterMethods(t *testing.T) { t.Fatalf("convertMarkdownV1() = %q", got) } if got := c.convertDiv(cardObj{ - "text": cardObj{"tag": "text", "property": cardObj{"content": "Title"}, "text_size": "notation"}, + "text": cardObj{"tag": "text", "property": cardObj{"content": "Title", "textStyle": cardObj{"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" { @@ -164,9 +174,22 @@ func TestCardConverterMethods(t *testing.T) { }, "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}" { + // select_person with no option text: names resolved from option_users attachment + if got := c.convertSelect(cardObj{ + "options": []interface{}{ + cardObj{"value": "opt_alice"}, + cardObj{"value": "opt_bob"}, + }, + "selectedValues": []interface{}{"opt_alice"}, + }, "select_person", true); got != "{โœ“Alice / Bob}(multi type:person)" { + t.Fatalf("convertSelect(person no-text) = %q", got) + } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{๐Ÿ–ผ๏ธ Image 1(1) / โœ“๐Ÿ–ผ๏ธ Image 2(2)}" { t.Fatalf("convertSelectImg() = %q", got) } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "opt_a", "imageID": "img_1"}, cardObj{"value": "opt_b"}}, "selectedValues": []interface{}{"opt_a"}}, ""); got != "{โœ“๐Ÿ–ผ๏ธ Image 1(opt_a)(img_key:img_v3_test_key1) / ๐Ÿ–ผ๏ธ Image 2(opt_b)}" { + t.Fatalf("convertSelectImg(with imageID) = %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) } @@ -176,10 +199,10 @@ func TestCardConverterMethods(t *testing.T) { 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)" { + if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "๐Ÿ–ผ๏ธ Poster(img_key:img_v3_test_key1)" { 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)" { + if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "๐Ÿ–ผ๏ธ 2 image(s)(keys:img_v3_test_key1,img_2)" { t.Fatalf("convertImgCombination() = %q", got) } if got := c.convertChart(cardObj{"chartSpec": cardObj{ @@ -191,7 +214,7 @@ func TestCardConverterMethods(t *testing.T) { cardObj{"month": "Jan", "value": 10}, cardObj{"month": "Feb", "value": 20}, }}, - }}, ""); got != "๐Ÿ“Š SalesBar chart\nSummary: Jan:10, Feb:20" { + }}, ""); got != "๐Ÿ“Š Sales (Bar chart)\nSummary: Jan:10, Feb:20" { t.Fatalf("convertChart() = %q", got) } if got := c.convertAudio(cardObj{"fileID": "audio_1"}, ""); got != "๐ŸŽต Audio(key:audio_1)" { @@ -208,25 +231,34 @@ func TestCardConverterMethods(t *testing.T) { "rows": []interface{}{ cardObj{ "name": cardObj{"data": "Alice"}, - "score": cardObj{"data": float64(95.5)}, + "score": cardObj{"data": "95.5"}, }, }, - }); got != "| Name | Score |\n|------|------|\n| Alice | 95.50 |" { + }); got != "| Name | Score |\n|------|------|\n| Alice | 95.5 |" { 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)" { + if got := c.extractTableCellValue("[map[text:VIP] map[text:Premium]]"); got != "VIP, Premium" { + t.Fatalf("extractTableCellValue(go-format array) = %q", got) + } + if got := c.extractTableCellValue("[map[text:VIP Plus] map[text:Premium Pro]]"); got != "VIP Plus, Premium Pro" { + t.Fatalf("extractTableCellValue(go-format array with spaces) = %q", got) + } + if got := c.extractTableCellValue("[map[bold:true text:VIP Plus] map[text:Premium Pro bold:false]]"); got != "VIP Plus, Premium Pro" { + t.Fatalf("extractTableCellValue(go-format array multi-key) = %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)" { + 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)" { + if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "ou_person"}}}); got != "user(id:u1), Alice(open_id:ou_person)" { t.Fatalf("convertPersonList() = %q", got) } - if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "๐Ÿ‘ค(id:ou_person)" { + if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "๐Ÿ‘ค Alice(open_id:ou_person)" { t.Fatalf("convertAvatar() = %q", got) } if got := c.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { @@ -241,21 +273,54 @@ func TestCardConverterMethods(t *testing.T) { if got := (interactiveConverter{}).Convert(&ConvertContext{RawContent: `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"inside\"}}]}}"}`}); got != "\ninside\n" { t.Fatalf("interactiveConverter.Convert() = %q", got) } + + // C001: collapsible panel in concise mode (collapsed) must still render content + cc := newTestCardConverter(cardModeConcise) + if got := cc.convertCollapsiblePanel(cardObj{ + "expanded": false, + "header": cardObj{"title": cardObj{"content": "Details"}}, + "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "hidden info"}}}, + }, ""); !strings.Contains(got, "hidden info") { + t.Fatalf("convertCollapsiblePanel(concise,collapsed) = %q, want content rendered", got) + } + + // C002: extractHeaderSubtitle + if got := c.extractHeaderSubtitle(cardObj{"property": cardObj{ + "subtitle": cardObj{"property": cardObj{"content": "Q3 Budget"}}, + }}); got != "Q3 Budget" { + t.Fatalf("extractHeaderSubtitle() = %q", got) + } + + // C003: extractHeaderTags + if got := c.extractHeaderTags(cardObj{"textTagList": []interface{}{ + cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Approved"}}}, + }}); got != "ใ€ŒApprovedใ€" { + t.Fatalf("extractHeaderTags() = %q", got) + } + + // C007: convertButton disabled with disabledTips + if got := c.convertButton(cardObj{ + "text": cardObj{"content": "Submit"}, + "disabled": true, + "disabledTips": cardObj{"content": "Only managers can submit"}, + }, ""); got != "[Submit โœ—](tips:\"Only managers can submit\")" { + t.Fatalf("convertButton(disabled+tips) = %q", got) + } } func TestConvertAtWithMentions(t *testing.T) { mentions := []interface{}{ map[string]interface{}{ "key": "@_user_1", - "id": "ou_6b64bef911a5a3ea763df8ffd9258f59", - "name": "็‡•ๅฟ ๆฏ…", + "id": "ou_xxxx", + "name": "ๆต‹่ฏ•็”จๆˆท", }, } attachment := cardObj{ "at_users": cardObj{ - "cde8a6c8": cardObj{ - "user_id": "754700000001", - "content": "็‡•ๅฟ ๆฏ…", + "xxxxx": cardObj{ + "user_id": "0000000001", + "content": "ๆต‹่ฏ•็”จๆˆท", "mention_key": "@_user_1", }, }, @@ -267,7 +332,7 @@ func TestConvertAtWithMentions(t *testing.T) { attachment: attachment, mentionsByKey: buildMentionsByKey(mentions), } - if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@็‡•ๅฟ ๆฏ…(ou_6b64bef911a5a3ea763df8ffd9258f59)" { + if got := concise.convertAt(cardObj{"userID": "xxxxx"}); got != "@ๆต‹่ฏ•็”จๆˆท(ou_xxxx)" { t.Fatalf("convertAt(concise with mentions) = %q", got) } @@ -277,7 +342,7 @@ func TestConvertAtWithMentions(t *testing.T) { attachment: attachment, mentionsByKey: buildMentionsByKey(mentions), } - if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@็‡•ๅฟ ๆฏ…(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" { + if got := detailed.convertAt(cardObj{"userID": "xxxxx"}); got != "@ๆต‹่ฏ•็”จๆˆท(open_id:ou_xxxx)" { t.Fatalf("convertAt(detailed with mentions) = %q", got) } @@ -299,19 +364,878 @@ func TestConvertAtWithMentions(t *testing.T) { mode: cardModeDetailed, attachment: cardObj{ "at_users": cardObj{ - "cde8a6c8": cardObj{ - "user_id": "754700000001", - "content": "็‡•ๅฟ ๆฏ…", + "xxxxx": cardObj{ + "user_id": "0000000001", + "content": "ๆต‹่ฏ•็”จๆˆท", "mention_key": "@_user_1", }, }, }, } - if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@็‡•ๅฟ ๆฏ…(user_id:754700000001)" { + if got := nilMentions.convertAt(cardObj{"userID": "xxxxx"}); got != "@ๆต‹่ฏ•็”จๆˆท(user_id:0000000001)" { t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got) } } +func TestCardConverterCoverageGaps(t *testing.T) { + dc := newTestCardConverter(cardModeDetailed) + cc := newTestCardConverter(cardModeConcise) + + // โ”€โ”€ convertCard edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // invalid JSON โ†’ fallback label + if got := convertCard("not-json", nil); got != "[interactive card]" { + t.Fatalf("convertCard(invalid JSON) = %q", got) + } + // empty card body โ†’ card wrapper with no content + if got := convertCard(`{"json_card":"{\"body\":{\"elements\":[]}}"}`, nil); got != "\n" { + t.Fatalf("convertCard(empty body) = %q", got) + } + // card_schema field is read without error + withSchema := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hi\"}}]}}","card_schema":2}` + if got := convertCard(withSchema, nil); !strings.Contains(got, "hi") { + t.Fatalf("convertCard(card_schema) = %q", got) + } + + // โ”€โ”€ buildMentionsByKey โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // non-map entry is skipped gracefully + m := buildMentionsByKey([]interface{}{ + "not-a-map", + map[string]interface{}{"key": "@_user_x", "name": "Test User"}, + map[string]interface{}{"name": "no-key"}, + }) + if _, ok := m["@_user_x"]; !ok { + t.Fatal("buildMentionsByKey: valid entry missing") + } + if len(m) != 1 { + t.Fatalf("buildMentionsByKey: expected 1 entry, got %d", len(m)) + } + + // โ”€โ”€ convertLegacyCard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no texts โ†’ fallback + if got := convertLegacyCard(cardObj{}); got != "[interactive card]" { + t.Fatalf("convertLegacyCard(empty) = %q", got) + } + // elements at top level (not under body) + legacyTopLevel := cardObj{ + "header": cardObj{"title": cardObj{"content": "Title"}}, + "elements": []interface{}{cardObj{"tag": "markdown", "content": "body text"}}, + } + if got := convertLegacyCard(legacyTopLevel); !strings.Contains(got, "Title") { + t.Fatalf("convertLegacyCard(top-level elements) = %q", got) + } + // body elements path + legacyBodyElements := cardObj{ + "body": cardObj{"elements": []interface{}{cardObj{"tag": "markdown", "content": "body text"}}}, + } + if got := convertLegacyCard(legacyBodyElements); !strings.Contains(got, "body text") { + t.Fatalf("convertLegacyCard(body elements) = %q", got) + } + + // โ”€โ”€ legacyExtractTexts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + var out []string + // div with text.content + legacyExtractTexts([]interface{}{ + cardObj{"tag": "div", "text": cardObj{"content": "div-text"}}, + cardObj{"tag": "plain_text", "content": "plain-content"}, + }, &out) + if len(out) != 2 || out[0] != "div-text" || out[1] != "plain-content" { + t.Fatalf("legacyExtractTexts(div+plain_text) = %v", out) + } + // column_set recurses into columns + out = nil + legacyExtractTexts([]interface{}{ + cardObj{"tag": "column_set", "columns": []interface{}{ + cardObj{"elements": []interface{}{cardObj{"tag": "markdown", "content": "col-text"}}}, + }}, + }, &out) + if len(out) != 1 || out[0] != "col-text" { + t.Fatalf("legacyExtractTexts(column_set) = %v", out) + } + // generic elements fallback + out = nil + legacyExtractTexts([]interface{}{ + cardObj{"tag": "unknown_parent", "elements": []interface{}{ + cardObj{"tag": "markdown", "content": "nested"}, + }}, + }, &out) + if len(out) != 1 || out[0] != "nested" { + t.Fatalf("legacyExtractTexts(elements fallback) = %v", out) + } + // non-map element skipped + out = nil + legacyExtractTexts([]interface{}{"not-a-map"}, &out) + if len(out) != 0 { + t.Fatalf("legacyExtractTexts(non-map) = %v", out) + } + + // โ”€โ”€ convert (cardConverter.convert) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + conv := &cardConverter{mode: cardModeDetailed} + // invalid JSON + if got := conv.convert("bad-json", 0); got != "\n[Unable to parse card content]\n" { + t.Fatalf("convert(bad-json) = %q", got) + } + // subtitle only (no title) + subtitleOnlyCard := `{"header":{"subtitle":{"content":"Sub"}},"body":{"elements":[{"tag":"text","property":{"content":"body"}}]}}` + if got := conv.convert(subtitleOnlyCard, 0); !strings.Contains(got, `subtitle="Sub"`) { + t.Fatalf("convert(subtitle only) = %q", got) + } + // title + subtitle + bothCard := `{"header":{"title":{"content":"T"},"subtitle":{"content":"S"}},"body":{"elements":[{"tag":"text","property":{"content":"b"}}]}}` + if got := conv.convert(bothCard, 0); !strings.Contains(got, `title="T" subtitle="S"`) { + t.Fatalf("convert(title+subtitle) = %q", got) + } + // headerTags present + tagsCard := `{"header":{"textTagList":[{"tag":"text_tag","property":{"text":{"content":"Tag1"}}}]},"body":{"elements":[{"tag":"text","property":{"content":"body"}}]}}` + if got := conv.convert(tagsCard, 0); !strings.Contains(got, "ใ€ŒTag1ใ€") { + t.Fatalf("convert(headerTags) = %q", got) + } + // empty body + noBodyCard := `{"header":{"title":{"content":"Empty"}}}` + if got := conv.convert(noBodyCard, 0); !strings.Contains(got, `title="Empty"`) { + t.Fatalf("convert(empty body) = %q", got) + } + + // โ”€โ”€ extractHeaderTitle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // flat header["title"] (no property wrapper) + if got := dc.extractHeaderTitle(cardObj{"title": cardObj{"content": "Flat Title"}}); got != "Flat Title" { + t.Fatalf("extractHeaderTitle(flat) = %q", got) + } + // no title at all + if got := dc.extractHeaderTitle(cardObj{}); got != "" { + t.Fatalf("extractHeaderTitle(empty) = %q", got) + } + + // โ”€โ”€ extractHeaderSubtitle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // flat path + if got := dc.extractHeaderSubtitle(cardObj{"subtitle": cardObj{"content": "Flat Sub"}}); got != "Flat Sub" { + t.Fatalf("extractHeaderSubtitle(flat) = %q", got) + } + + // โ”€โ”€ extractHeaderTags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // flat header (no property wrapper) + if got := dc.extractHeaderTags(cardObj{"textTagList": []interface{}{ + cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Flat"}}}, + }}); got != "ใ€ŒFlatใ€" { + t.Fatalf("extractHeaderTags(flat) = %q", got) + } + // empty tag list + if got := dc.extractHeaderTags(cardObj{}); got != "" { + t.Fatalf("extractHeaderTags(empty) = %q", got) + } + // all tags produce empty content + if got := dc.extractHeaderTags(cardObj{"textTagList": []interface{}{cardObj{"tag": "text_tag", "property": cardObj{}}}}); got != "" { + t.Fatalf("extractHeaderTags(all-empty) = %q", got) + } + // non-map entry in tag list is skipped + if got := dc.extractHeaderTags(cardObj{"textTagList": []interface{}{ + "not-a-map", + cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Valid"}}}, + }}); got != "ใ€ŒValidใ€" { + t.Fatalf("extractHeaderTags(non-map skip) = %q", got) + } + + // โ”€โ”€ convertBody โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // property-wrapped elements + bodyWithProp := cardObj{ + "property": cardObj{ + "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "prop-elem"}}}, + }, + } + if got := dc.convertBody(bodyWithProp); got != "prop-elem" { + t.Fatalf("convertBody(property-wrapped) = %q", got) + } + // empty body + if got := dc.convertBody(cardObj{}); got != "" { + t.Fatalf("convertBody(empty) = %q", got) + } + + // โ”€โ”€ convertElements โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // non-map element is skipped + if got := dc.convertElements([]interface{}{"not-a-map", cardObj{"tag": "text", "property": cardObj{"content": "ok"}}}, 0); got != "ok" { + t.Fatalf("convertElements(non-map skip) = %q", got) + } + + // โ”€โ”€ extractTextContent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.extractTextContent(nil); got != "" { + t.Fatalf("extractTextContent(nil) = %q", got) + } + if got := dc.extractTextContent("string-val"); got != "string-val" { + t.Fatalf("extractTextContent(string) = %q", got) + } + // non-map, non-string โ†’ "" + if got := dc.extractTextContent(42); got != "" { + t.Fatalf("extractTextContent(int) = %q", got) + } + + // โ”€โ”€ convertPlainText โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertPlainText(cardObj{"content": ""}); got != "" { + t.Fatalf("convertPlainText(empty) = %q", got) + } + + // โ”€โ”€ convertMarkdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // elements path + if got := dc.convertMarkdown(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "elem-md"}}}}); got != "elem-md" { + t.Fatalf("convertMarkdown(elements) = %q", got) + } + // empty โ†’ "" + if got := dc.convertMarkdown(cardObj{}); got != "" { + t.Fatalf("convertMarkdown(empty) = %q", got) + } + + // โ”€โ”€ convertMarkdownV1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // content fallback (no elements, no fallback) + if got := dc.convertMarkdownV1(cardObj{}, cardObj{"content": "v1-content"}); got != "v1-content" { + t.Fatalf("convertMarkdownV1(content fallback) = %q", got) + } + // all empty + if got := dc.convertMarkdownV1(cardObj{}, cardObj{}); got != "" { + t.Fatalf("convertMarkdownV1(empty) = %q", got) + } + // elements path + if got := dc.convertMarkdownV1(cardObj{}, cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "v1-elem"}}}}); got != "v1-elem" { + t.Fatalf("convertMarkdownV1(elements) = %q", got) + } + + // โ”€โ”€ convertLink โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no URL โ†’ return content as-is + if got := dc.convertLink(cardObj{"content": "Plain Link"}); got != "Plain Link" { + t.Fatalf("convertLink(no url) = %q", got) + } + // empty content defaults to "Link" + if got := dc.convertLink(cardObj{}); got != "Link" { + t.Fatalf("convertLink(empty content) = %q", got) + } + + // โ”€โ”€ convertEmoji โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertEmoji(cardObj{"key": "WAVE"}); got != ":WAVE:" { + t.Fatalf("convertEmoji(unknown key) = %q", got) + } + + // โ”€โ”€ convertLocalDatetime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // float64 milliseconds + if got := dc.convertLocalDatetime(cardObj{"milliseconds": float64(1710500000000)}); got == "" { + t.Fatal("convertLocalDatetime(float64) returned empty") + } + // no milliseconds โ†’ fallback text + if got := dc.convertLocalDatetime(cardObj{"fallbackText": "sometime"}); got != "sometime" { + t.Fatalf("convertLocalDatetime(fallback) = %q", got) + } + + // โ”€โ”€ convertBlockquote โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // elements path + if got := dc.convertBlockquote(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "quote elem"}}}}); got != "> quote elem" { + t.Fatalf("convertBlockquote(elements) = %q", got) + } + // empty โ†’ "" + if got := dc.convertBlockquote(cardObj{}); got != "" { + t.Fatalf("convertBlockquote(empty) = %q", got) + } + + // โ”€โ”€ convertCodeBlock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no language โ†’ "plaintext" + if got := dc.convertCodeBlock(cardObj{"contents": []interface{}{cardObj{"contents": []interface{}{cardObj{"content": "x"}}}}}); !strings.HasPrefix(got, "```plaintext") { + t.Fatalf("convertCodeBlock(no language) = %q", got) + } + // non-map line content skipped + if got := dc.convertCodeBlock(cardObj{"language": "go", "contents": []interface{}{"not-a-map"}}); got != "```go\n```" { + t.Fatalf("convertCodeBlock(non-map line) = %q", got) + } + + // โ”€โ”€ convertHeading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // elements path + if got := dc.convertHeading(cardObj{"level": float64(1), "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Title"}}}}); got != "# Title" { + t.Fatalf("convertHeading(elements) = %q", got) + } + // level < 1 โ†’ clamped to 1 + if got := dc.convertHeading(cardObj{"level": float64(0), "content": "H"}); got != "# H" { + t.Fatalf("convertHeading(level=0) = %q", got) + } + // level > 6 โ†’ clamped to 6 + if got := dc.convertHeading(cardObj{"level": float64(9), "content": "H"}); got != "###### H" { + t.Fatalf("convertHeading(level=9) = %q", got) + } + + // โ”€โ”€ convertFallbackText โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // elements path + if got := dc.convertFallbackText(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "fb-elem"}}}}); got != "fb-elem" { + t.Fatalf("convertFallbackText(elements) = %q", got) + } + // empty โ†’ "" + if got := dc.convertFallbackText(cardObj{}); got != "" { + t.Fatalf("convertFallbackText(empty) = %q", got) + } + + // โ”€โ”€ convertNumberTag โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no URL โ†’ return text + if got := dc.convertNumberTag(cardObj{"text": cardObj{"content": "99"}}); got != "99" { + t.Fatalf("convertNumberTag(no url) = %q", got) + } + // empty text โ†’ "" + if got := dc.convertNumberTag(cardObj{}); got != "" { + t.Fatalf("convertNumberTag(empty) = %q", got) + } + + // โ”€โ”€ convertUnknown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // detailed mode with no matching field โ†’ "[Unknown content](tag:X)" + if got := dc.convertUnknown(cardObj{}, "exotic"); got != "[Unknown content](tag:exotic)" { + t.Fatalf("convertUnknown(detailed, no field) = %q", got) + } + // concise mode โ†’ "[Unknown content]" + if got := cc.convertUnknown(cardObj{}, "exotic"); got != "[Unknown content]" { + t.Fatalf("convertUnknown(concise) = %q", got) + } + // elements fallback + if got := dc.convertUnknown(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "deep"}}}}, "x"); got != "deep" { + t.Fatalf("convertUnknown(elements) = %q", got) + } + + // โ”€โ”€ allColumnsAreButtons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if allColumnsAreButtons(nil) { + t.Fatal("allColumnsAreButtons(nil) should be false") + } + // contains newline โ†’ false + if allColumnsAreButtons([]string{"[A]\n[B]"}) { + t.Fatal("allColumnsAreButtons(newline) should be false") + } + + // โ”€โ”€ convertColumn โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertColumn(cardObj{}, 0); got != "" { + t.Fatalf("convertColumn(empty) = %q", got) + } + + // โ”€โ”€ convertRepeat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertRepeat(cardObj{}); got != "" { + t.Fatalf("convertRepeat(empty) = %q", got) + } + + // โ”€โ”€ convertButton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no text โ†’ "Button" + if got := dc.convertButton(cardObj{}, ""); got != "[Button]" { + t.Fatalf("convertButton(no text) = %q", got) + } + // confirm dialog + if got := dc.convertButton(cardObj{ + "text": cardObj{"content": "OK"}, + "confirm": cardObj{"title": cardObj{"content": "Sure?"}, "text": cardObj{"content": "Cannot undo"}}, + }, ""); got != `[OK](confirm:"Sure?: Cannot undo")` { + t.Fatalf("convertButton(confirm) = %q", got) + } + // action without URL (loop continues, no URL appended) + if got := dc.convertButton(cardObj{ + "text": cardObj{"content": "Do"}, + "actions": []interface{}{cardObj{"type": "other", "action": cardObj{}}}, + }, ""); got != "[Do]" { + t.Fatalf("convertButton(action no url) = %q", got) + } + // non-map action skipped + if got := dc.convertButton(cardObj{ + "text": cardObj{"content": "Do"}, + "actions": []interface{}{"not-a-map"}, + }, ""); got != "[Do]" { + t.Fatalf("convertButton(non-map action) = %q", got) + } + + // โ”€โ”€ convertActions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // empty actions โ†’ "" + if got := dc.convertActions(cardObj{}); got != "" { + t.Fatalf("convertActions(empty) = %q", got) + } + + // โ”€โ”€ convertOverflow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // empty options โ†’ "" + if got := dc.convertOverflow(cardObj{}); got != "" { + t.Fatalf("convertOverflow(empty) = %q", got) + } + // option with value (no URL) + if got := dc.convertOverflow(cardObj{"options": []interface{}{ + cardObj{"text": cardObj{"content": "Edit"}}, + cardObj{"text": cardObj{"content": "Remove"}, "value": "rm"}, + }}); got != "โ‹ฎ Edit, Remove(rm)" { + t.Fatalf("convertOverflow(value) = %q", got) + } + // option with URL via actions + if got := dc.convertOverflow(cardObj{"options": []interface{}{ + cardObj{ + "text": cardObj{"content": "Go"}, + "actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, + }, + }}); got != "โ‹ฎ [Go](https://example.com)" { + t.Fatalf("convertOverflow(url action) = %q", got) + } + // option with no text โ†’ skipped + if got := dc.convertOverflow(cardObj{"options": []interface{}{cardObj{}}}); got != "โ‹ฎ " { + t.Fatalf("convertOverflow(no-text option) = %q", got) + } + + // โ”€โ”€ convertSelect (non-multi paths) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // initialOption selects by value + if got := dc.convertSelect(cardObj{ + "options": []interface{}{cardObj{"text": cardObj{"content": "A"}, "value": "a"}, cardObj{"text": cardObj{"content": "B"}, "value": "b"}}, + "initialOption": "b", + }, "select_static", false); got != "{A / โœ“B}" { + t.Fatalf("convertSelect(initialOption) = %q", got) + } + // initialIndex selects by position + if got := dc.convertSelect(cardObj{ + "options": []interface{}{cardObj{"text": cardObj{"content": "First"}, "value": "f"}, cardObj{"text": cardObj{"content": "Second"}, "value": "s"}}, + "initialIndex": float64(0), + }, "select_static", false); got != "{โœ“First / Second}" { + t.Fatalf("convertSelect(initialIndex) = %q", got) + } + // empty options โ†’ placeholder + if got := dc.convertSelect(cardObj{ + "placeholder": cardObj{"content": "Pick one"}, + }, "sel", false); got != "{Pick one โ–ผ}" { + t.Fatalf("convertSelect(empty+placeholder) = %q", got) + } + // empty options, default placeholder + if got := dc.convertSelect(cardObj{}, "sel", false); got != "{Please select โ–ผ}" { + t.Fatalf("convertSelect(default placeholder) = %q", got) + } + // no selected โ†’ last option gets arrow + if got := dc.convertSelect(cardObj{ + "options": []interface{}{cardObj{"text": cardObj{"content": "X"}, "value": "x"}, cardObj{"text": cardObj{"content": "Y"}, "value": "y"}}, + }, "sel", false); got != "{X / Y โ–ผ}" { + t.Fatalf("convertSelect(no-selected) = %q", got) + } + // option with empty text + empty value โ†’ skipped + if got := dc.convertSelect(cardObj{ + "options": []interface{}{cardObj{"value": ""}, cardObj{"text": cardObj{"content": "Valid"}, "value": "v"}}, + }, "sel", false); got != "{Valid โ–ผ}" { + t.Fatalf("convertSelect(skip empty option) = %q", got) + } + // option value used as text when no text element + if got := dc.convertSelect(cardObj{ + "options": []interface{}{cardObj{"value": "raw-val"}}, + }, "sel", false); got != "{raw-val โ–ผ}" { + t.Fatalf("convertSelect(value as text) = %q", got) + } + + // โ”€โ”€ convertSelectImg โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // imageID with only token (no origin_key) + imgTokenOnly := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "images": cardObj{ + "img-tok": cardObj{"token": "tok-abc"}, + }, + }, + } + if got := imgTokenOnly.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "v", "imageID": "img-tok"}}}, ""); !strings.Contains(got, "img_token:tok-abc") { + t.Fatalf("convertSelectImg(token only) = %q", got) + } + + // โ”€โ”€ convertInput โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // defaultValue path + if got := dc.convertInput(cardObj{"defaultValue": "prefilled"}, ""); got != "prefilled___" { + t.Fatalf("convertInput(defaultValue) = %q", got) + } + // no label, no placeholder โ†’ "_____" + if got := dc.convertInput(cardObj{}, ""); got != "_____" { + t.Fatalf("convertInput(empty) = %q", got) + } + + // โ”€โ”€ convertDatePicker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // time picker with value + if got := dc.convertDatePicker(cardObj{"initialTime": "14:30"}, "", "time"); got != "๐Ÿ• 14:30" { + t.Fatalf("convertDatePicker(time) = %q", got) + } + // datetime picker with value + if got := dc.convertDatePicker(cardObj{"initialDatetime": "2026-06-03T12:00:00Z"}, "", "datetime"); got != "๐Ÿ“… 2026-06-03T12:00:00Z" { + t.Fatalf("convertDatePicker(datetime) = %q", got) + } + // default picker type + if got := dc.convertDatePicker(cardObj{}, "", "other"); !strings.HasPrefix(got, "๐Ÿ“… ") { + t.Fatalf("convertDatePicker(default type) = %q", got) + } + // no value โ†’ placeholder + if got := dc.convertDatePicker(cardObj{"placeholder": cardObj{"content": "Pick date"}}, "", "date"); got != "๐Ÿ“… Pick date" { + t.Fatalf("convertDatePicker(placeholder) = %q", got) + } + // normalise ms timestamp + if got := dc.convertDatePicker(cardObj{"initialDate": "1710500000000"}, "", "date"); !strings.HasPrefix(got, "๐Ÿ“… ") || got == "๐Ÿ“… 1710500000000" { + t.Fatalf("convertDatePicker(ms timestamp) = %q", got) + } + + // โ”€โ”€ convertImage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // title overrides alt + if got := dc.convertImage(cardObj{"alt": cardObj{"content": "Alt"}, "title": cardObj{"content": "Title"}, "imageID": "img_1"}, ""); !strings.Contains(got, "Title") { + t.Fatalf("convertImage(title override) = %q", got) + } + // token-only image ID + tokenConverter := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "images": cardObj{"img-tok": cardObj{"token": "tok-xyz"}}, + }, + } + if got := tokenConverter.convertImage(cardObj{"imageID": "img-tok"}, ""); !strings.Contains(got, "img_token:tok-xyz") { + t.Fatalf("convertImage(token only) = %q", got) + } + // imageID not in attachment โ†’ fallback img_key:imageID + if got := dc.convertImage(cardObj{"imageID": "unknown-img"}, ""); !strings.Contains(got, "img_key:unknown-img") { + t.Fatalf("convertImage(no attachment entry) = %q", got) + } + + // โ”€โ”€ convertImgCombination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // empty list + if got := dc.convertImgCombination(cardObj{}); got != "" { + t.Fatalf("convertImgCombination(empty) = %q", got) + } + // token-only image + if got := tokenConverter.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img-tok"}}}); !strings.Contains(got, "tok-xyz") { + t.Fatalf("convertImgCombination(token) = %q", got) + } + // imageID with no attachment entry โ†’ raw imageID used as key + if got := dc.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "raw-id"}}}); !strings.Contains(got, "raw-id") { + t.Fatalf("convertImgCombination(raw id) = %q", got) + } + + // โ”€โ”€ convertChart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // no chartSpec title โ†’ type name becomes title + if got := dc.convertChart(cardObj{"chartSpec": cardObj{"type": "pie"}}, ""); !strings.Contains(got, "Pie chart") { + t.Fatalf("convertChart(no title, typed) = %q", got) + } + + // โ”€โ”€ extractChartSummary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // array-format data (VChart series) + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{ + "type": "line", + "xField": "x", + "yField": "y", + "data": []interface{}{cardObj{"id": "s1", "values": []interface{}{cardObj{"x": "A", "y": 1}}}}, + }}, "line"); got != "A:1" { + t.Fatalf("extractChartSummary(array data) = %q", got) + } + // pie chart + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{ + "type": "pie", + "categoryField": "cat", + "valueField": "val", + "data": cardObj{"values": []interface{}{cardObj{"cat": "A", "val": 10}, cardObj{"cat": "B", "val": 20}}}, + }}, "pie"); got != "A:10, B:20" { + t.Fatalf("extractChartSummary(pie) = %q", got) + } + // pie with missing fields โ†’ fallback count + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{ + "type": "pie", + "data": cardObj{"values": []interface{}{cardObj{"cat": "X"}}}, + }}, "pie"); !strings.Contains(got, "1 data point") { + t.Fatalf("extractChartSummary(pie missing fields) = %q", got) + } + // unknown chart type โ†’ count fallback + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{ + "type": "radar", + "data": cardObj{"values": []interface{}{cardObj{"x": 1}, cardObj{"x": 2}}}, + }}, "radar"); !strings.Contains(got, "2 data point") { + t.Fatalf("extractChartSummary(unknown type) = %q", got) + } + // line/bar with missing fields โ†’ count fallback + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{ + "type": "bar", + "data": cardObj{"values": []interface{}{cardObj{"x": "a"}}}, + }}, "bar"); !strings.Contains(got, "1 data point") { + t.Fatalf("extractChartSummary(bar missing fields) = %q", got) + } + // no chartSpec โ†’ "" + if got := dc.extractChartSummary(cardObj{}, "line"); got != "" { + t.Fatalf("extractChartSummary(no chartSpec) = %q", got) + } + // empty values โ†’ "" + if got := dc.extractChartSummary(cardObj{"chartSpec": cardObj{"data": cardObj{"values": []interface{}{}}}}, "line"); got != "" { + t.Fatalf("extractChartSummary(empty values) = %q", got) + } + + // โ”€โ”€ convertAudio โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // audioID fallback (fileID empty) + if got := dc.convertAudio(cardObj{"audioID": "fake-audio-id"}, ""); got != "๐ŸŽต Audio(key:fake-audio-id)" { + t.Fatalf("convertAudio(audioID) = %q", got) + } + // no ID + if got := dc.convertAudio(cardObj{}, ""); got != "๐ŸŽต Audio" { + t.Fatalf("convertAudio(no id) = %q", got) + } + + // โ”€โ”€ convertTable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // column with no displayName โ†’ use name + if got := dc.convertTable(cardObj{ + "columns": []interface{}{cardObj{"name": "col1"}}, + "rows": []interface{}{cardObj{"col1": cardObj{"data": "v"}}}, + }); !strings.Contains(got, "col1") { + t.Fatalf("convertTable(no displayName) = %q", got) + } + + // โ”€โ”€ extractTableCellValue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // float64 + if got := dc.extractTableCellValue(float64(3.14)); got != "3.14" { + t.Fatalf("extractTableCellValue(float64) = %q", got) + } + // cardObj (map) + if got := dc.extractTableCellValue(cardObj{"content": "map-val"}); got != "map-val" { + t.Fatalf("extractTableCellValue(cardObj) = %q", got) + } + // unknown type โ†’ "" + if got := dc.extractTableCellValue(true); got != "" { + t.Fatalf("extractTableCellValue(bool) = %q", got) + } + + // โ”€โ”€ convertPerson โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // concise mode with known person + if got := cc.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "Alice" { + t.Fatalf("convertPerson(concise, known) = %q", got) + } + // notation fallback when person not in attachment + withNotation := &cardConverter{mode: cardModeDetailed, attachment: cardObj{}} + if got := withNotation.convertPerson(cardObj{"userID": "ou_unknown", "notation": cardObj{"content": "Unknown User"}}, ""); !strings.Contains(got, "Unknown User") { + t.Fatalf("convertPerson(notation) = %q", got) + } + // no name, detailed + noPersonAttachment := &cardConverter{mode: cardModeDetailed, attachment: cardObj{}} + if got := noPersonAttachment.convertPerson(cardObj{"userID": "fake-uid-001"}, ""); got != "user(open_id:fake-uid-001)" { + t.Fatalf("convertPerson(no name, detailed) = %q", got) + } + // no name, concise + if got := cc.convertPerson(cardObj{"userID": "fake-uid-002"}, ""); got != "fake-uid-002" { + t.Fatalf("convertPerson(no name, concise) = %q", got) + } + // empty userID + if got := dc.convertPerson(cardObj{}, ""); got != "" { + t.Fatalf("convertPerson(empty userID) = %q", got) + } + + // โ”€โ”€ convertPersonV1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // concise mode with known person + if got := cc.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "Alice" { + t.Fatalf("convertPersonV1(concise, known) = %q", got) + } + // no name, detailed + if got := noPersonAttachment.convertPersonV1(cardObj{"userID": "fake-uid-003"}, ""); got != "user(open_id:fake-uid-003)" { + t.Fatalf("convertPersonV1(no name, detailed) = %q", got) + } + // no name, concise + noPersonConcise := &cardConverter{mode: cardModeConcise, attachment: cardObj{}} + if got := noPersonConcise.convertPersonV1(cardObj{"userID": "fake-uid-004"}, ""); got != "fake-uid-004" { + t.Fatalf("convertPersonV1(no name, concise) = %q", got) + } + // empty userID + if got := dc.convertPersonV1(cardObj{}, ""); got != "" { + t.Fatalf("convertPersonV1(empty userID) = %q", got) + } + + // โ”€โ”€ convertPersonList โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // concise mode + if got := cc.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "ou_person"}}}); got != "Alice" { + t.Fatalf("convertPersonList(concise) = %q", got) + } + // person with no id โ†’ "user" + if got := dc.convertPersonList(cardObj{"persons": []interface{}{cardObj{}}}); got != "user" { + t.Fatalf("convertPersonList(no id) = %q", got) + } + // empty list + if got := dc.convertPersonList(cardObj{}); got != "" { + t.Fatalf("convertPersonList(empty) = %q", got) + } + + // โ”€โ”€ convertAvatar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // concise mode with known person + if got := cc.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "๐Ÿ‘ค Alice" { + t.Fatalf("convertAvatar(concise, known) = %q", got) + } + // no name, with userID + noAvatar := &cardConverter{mode: cardModeConcise, attachment: cardObj{}} + if got := noAvatar.convertAvatar(cardObj{"userID": "fake-uid-005"}, ""); got != "๐Ÿ‘ค(id:fake-uid-005)" { + t.Fatalf("convertAvatar(no name, with id) = %q", got) + } + // no name, no userID + if got := noAvatar.convertAvatar(cardObj{}, ""); got != "๐Ÿ‘ค" { + t.Fatalf("convertAvatar(no name, no id) = %q", got) + } + + // โ”€โ”€ convertAt (no attachment) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + noAttachmentConverter := &cardConverter{mode: cardModeDetailed} + if got := noAttachmentConverter.convertAt(cardObj{"userID": "fake-uid-006"}); got != "@user(open_id:fake-uid-006)" { + t.Fatalf("convertAt(no attachment, detailed) = %q", got) + } + noAttachmentConcise := &cardConverter{mode: cardModeConcise} + if got := noAttachmentConcise.convertAt(cardObj{"userID": "fake-uid-007"}); got != "@fake-uid-007" { + t.Fatalf("convertAt(no attachment, concise) = %q", got) + } + // empty userID + if got := noAttachmentConverter.convertAt(cardObj{}); got != "" { + t.Fatalf("convertAt(empty userID) = %q", got) + } + // concise, no fromMentions, userName set but no actualUserID + conciseAt := &cardConverter{ + mode: cardModeConcise, + attachment: cardObj{ + "at_users": cardObj{"ou_nouid": cardObj{"content": "Test User"}}, + }, + } + if got := conciseAt.convertAt(cardObj{"userID": "ou_nouid"}); got != "@Test User(ou_nouid)" { + t.Fatalf("convertAt(concise, no actual uid) = %q", got) + } + + // โ”€โ”€ applyTextStyle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // strikethrough + if got := dc.applyTextStyle("text", cardObj{"textStyle": cardObj{"attributes": []interface{}{"strikethrough"}}}); got != "~~text~~" { + t.Fatalf("applyTextStyle(strikethrough) = %q", got) + } + + // โ”€โ”€ cardFormatMillisToISO8601 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := cardFormatMillisToISO8601("not-a-number"); got != "" { + t.Fatalf("cardFormatMillisToISO8601(invalid) = %q", got) + } + + // โ”€โ”€ cardNormalizeTimeFormat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // millisecond timestamp (13 digits) + if got := cardNormalizeTimeFormat("1710500000000"); got == "1710500000000" { + t.Fatal("cardNormalizeTimeFormat(ms) should normalize") + } + // empty string + if got := cardNormalizeTimeFormat(""); got != "" { + t.Fatalf("cardNormalizeTimeFormat(empty) = %q", got) + } +} + +func TestCardConverterRemainingBranches(t *testing.T) { + dc := newTestCardConverter(cardModeDetailed) + cc := newTestCardConverter(cardModeConcise) + + // โ”€โ”€ extractHeaderTitle (property-wrapped path) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.extractHeaderTitle(cardObj{"property": cardObj{"title": cardObj{"content": "Prop Title"}}}); got != "Prop Title" { + t.Fatalf("extractHeaderTitle(property-wrapped) = %q", got) + } + + // โ”€โ”€ convertNote edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // empty elements โ†’ "" + if got := dc.convertNote(cardObj{"elements": []interface{}{}}); got != "" { + t.Fatalf("convertNote(empty elements) = %q", got) + } + // non-map element skipped; element producing empty text skipped โ†’ "" + if got := dc.convertNote(cardObj{"elements": []interface{}{"not-a-map", cardObj{"tag": "card_header"}}}); got != "" { + t.Fatalf("convertNote(all-skip) = %q, want empty", got) + } + + // โ”€โ”€ convertColumnSet newline-join (non-button columns) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertColumnSet(cardObj{"columns": []interface{}{ + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "A"}}}}, + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "B"}}}}, + }}, 0); got != "A\n\nB" { + t.Fatalf("convertColumnSet(non-button) = %q", got) + } + // non-map column skipped + if got := dc.convertColumnSet(cardObj{"columns": []interface{}{"not-a-map"}}, 0); got != "" { + t.Fatalf("convertColumnSet(non-map col) = %q", got) + } + + // โ”€โ”€ convertAt remaining branches โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // detailed, userName set but no actualUserID โ†’ @Name(open_id:origKey) + noUID := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{"ou_nouid": cardObj{"content": "Test User"}}, + }, + } + if got := noUID.convertAt(cardObj{"userID": "ou_nouid"}); got != "@Test User(open_id:ou_nouid)" { + t.Fatalf("convertAt(detailed, no actualUID) = %q", got) + } + // detailed, no userName but actualUserID present โ†’ @user(user_id:X) + noName := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{"ou_noname": cardObj{"user_id": "fake-uid-010"}}, + }, + } + if got := noName.convertAt(cardObj{"userID": "ou_noname"}); got != "@user(user_id:fake-uid-010)" { + t.Fatalf("convertAt(detailed, no name, with actualUID) = %q", got) + } + // concise, fromMentions=true, actualUserID set + _ = cc // suppress unused + + // โ”€โ”€ lookupPersonName / lookupOptionUserName / getImageKeyAndToken nil attachment โ”€โ”€ + + nilAttach := &cardConverter{mode: cardModeDetailed} + if got := nilAttach.lookupPersonName("any"); got != "" { + t.Fatalf("lookupPersonName(nil attachment) = %q", got) + } + if got := nilAttach.lookupOptionUserName("any"); got != "" { + t.Fatalf("lookupOptionUserName(nil attachment) = %q", got) + } + k, tok := nilAttach.getImageKeyAndToken("any") + if k != "" || tok != "" { + t.Fatalf("getImageKeyAndToken(nil attachment) = %q, %q", k, tok) + } + + // โ”€โ”€ goMapArrayTexts edge case (text at end without bracket) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // text value is the last thing in the string with no closing bracket + if got := goMapArrayTexts("[map[text:last"); len(got) != 1 || got[0] != "last" { + t.Fatalf("goMapArrayTexts(no closing bracket) = %v", got) + } + // string that doesn't look like a go map array + if got := goMapArrayTexts("plain string"); got != nil { + t.Fatalf("goMapArrayTexts(plain) = %v", got) + } + + // โ”€โ”€ convertMarkdownElements non-map element skipped โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if got := dc.convertMarkdownElements([]interface{}{"not-a-map", cardObj{"tag": "text", "property": cardObj{"content": "valid"}}}); got != "valid" { + t.Fatalf("convertMarkdownElements(non-map skip) = %q", got) + } +} + func TestCardConverterExtractTextHelpers(t *testing.T) { c := newTestCardConverter(cardModeDetailed) @@ -371,9 +1295,44 @@ func TestCardConverterDispatch(t *testing.T) { 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: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "Alice(open_id:ou_person)"}, + {name: "person_v1", elem: cardObj{"tag": "person_v1", "property": cardObj{"userID": "ou_person"}}, want: "Alice(open_id:ou_person)"}, + {name: "person_list", elem: cardObj{"tag": "person_list", "property": cardObj{"persons": []interface{}{cardObj{"id": "ou_person"}}}}, want: "Alice(open_id:ou_person)"}, + {name: "avatar", elem: cardObj{"tag": "avatar", "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: "overflow", elem: cardObj{"tag": "overflow", "property": cardObj{"options": []interface{}{ + cardObj{"text": cardObj{"content": "Edit"}}, + cardObj{"text": cardObj{"content": "Delete"}, "value": "del"}, + }}}, want: "โ‹ฎ Edit, Delete(del)"}, + {name: "select_static non-multi", elem: cardObj{"tag": "select_static", "property": cardObj{ + "options": []interface{}{cardObj{"text": cardObj{"content": "Option A"}, "value": "a"}, cardObj{"text": cardObj{"content": "Option B"}, "value": "b"}}, + "initialOption": "a", + }}, want: "{โœ“Option A / Option B}"}, + {name: "multi_select_static", elem: cardObj{"tag": "multi_select_static", "property": cardObj{ + "options": []interface{}{cardObj{"text": cardObj{"content": "X"}, "value": "x"}, cardObj{"text": cardObj{"content": "Y"}, "value": "y"}}, + "selectedValues": []interface{}{"x"}, + }}, want: "{โœ“X / Y}(multi)"}, + {name: "select_img", elem: cardObj{"tag": "select_img", "property": cardObj{"options": []interface{}{cardObj{"value": "v1"}}, "selectedValues": []interface{}{"v1"}}}, want: "{โœ“๐Ÿ–ผ๏ธ Image 1(v1)}"}, + {name: "picker_time", elem: cardObj{"tag": "picker_time", "property": cardObj{"initialTime": "09:30"}}, want: "๐Ÿ• 09:30"}, + {name: "picker_datetime", elem: cardObj{"tag": "picker_datetime", "property": cardObj{"initialDatetime": "2026-06-03T10:00:00Z"}}, want: "๐Ÿ“… 2026-06-03T10:00:00Z"}, + {name: "img", elem: cardObj{"tag": "img", "property": cardObj{"alt": cardObj{"content": "Photo"}}}, want: "๐Ÿ–ผ๏ธ Photo"}, + {name: "img_combination", elem: cardObj{"tag": "img_combination", "property": cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_1"}}}}, want: "๐Ÿ–ผ๏ธ 2 image(s)(keys:img_v3_test_key1,img_v3_test_key1)"}, + {name: "table", elem: cardObj{"tag": "table", "property": cardObj{"columns": []interface{}{cardObj{"displayName": "Col", "name": "col"}}, "rows": []interface{}{cardObj{"col": cardObj{"data": "val"}}}}}, want: "| Col |\n|------|\n| val |"}, + {name: "chart", elem: cardObj{"tag": "chart", "property": cardObj{"chartSpec": cardObj{"title": cardObj{"text": "Q1"}, "type": "line", "xField": "x", "yField": "y", "data": cardObj{"values": []interface{}{cardObj{"x": "Jan", "y": 5}}}}}}, want: "๐Ÿ“Š Q1 (Line chart)\nSummary: Jan:5"}, + {name: "audio", elem: cardObj{"tag": "audio", "property": cardObj{"fileID": "fake-audio-key"}}, want: "๐ŸŽต Audio(key:fake-audio-key)"}, + {name: "video", elem: cardObj{"tag": "video", "property": cardObj{"fileID": "fake-video-key"}}, want: "๐ŸŽฌ Video(key:fake-video-key)"}, + {name: "collapsible_panel", elem: cardObj{"tag": "collapsible_panel", "property": cardObj{"expanded": true, "header": cardObj{"title": cardObj{"content": "More"}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "inside"}}}}}, want: "โ–ผ More\n inside\nโ–ฒ"}, + {name: "form", elem: cardObj{"tag": "form", "property": cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "fill me"}}}}}, want: "
\nfill me\n
"}, + {name: "number_tag", elem: cardObj{"tag": "number_tag", "property": cardObj{"text": cardObj{"content": "7"}, "url": cardObj{"url": "https://example.com/7"}}}, want: "[7](https://example.com/7)"}, + {name: "local_datetime", elem: cardObj{"tag": "local_datetime", "property": cardObj{"milliseconds": "1710500000000"}}, contains: "202"}, + {name: "list", elem: cardObj{"tag": "list", "property": cardObj{"items": []interface{}{cardObj{"level": float64(0), "type": "ul", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "x"}}}}}}}, want: "- x"}, + {name: "blockquote", elem: cardObj{"tag": "blockquote", "property": cardObj{"content": "quoted"}}, want: "> quoted"}, + {name: "code_block", elem: cardObj{"tag": "code_block", "property": cardObj{"language": "go", "contents": []interface{}{cardObj{"contents": []interface{}{cardObj{"content": "x:=1"}}}}}}, want: "```go\nx:=1```"}, + {name: "code_span", elem: cardObj{"tag": "code_span", "property": cardObj{"content": "foo"}}, want: "`foo`"}, + {name: "heading", elem: cardObj{"tag": "heading", "property": cardObj{"level": float64(3), "content": "H3"}}, want: "### H3"}, + {name: "fallback_text", elem: cardObj{"tag": "fallback_text", "property": cardObj{"text": cardObj{"content": "fallback"}}}, want: "fallback"}, + {name: "repeat", elem: cardObj{"tag": "repeat", "property": cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "rep"}}}}}, want: "rep"}, {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"}}}, @@ -381,7 +1340,7 @@ func TestCardConverterDispatch(t *testing.T) { {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: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "๐Ÿ–ผ๏ธ Poster(img_key:img_v3_test_key1)"}, {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"}}}, @@ -390,6 +1349,8 @@ func TestCardConverterDispatch(t *testing.T) { {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: "custom_icon suppressed", elem: cardObj{"tag": "custom_icon"}, want: ""}, + {name: "standard_icon suppressed", elem: cardObj{"tag": "standard_icon"}, want: ""}, {name: "unknown", elem: cardObj{"tag": "mystery", "property": cardObj{"title": cardObj{"content": "mystery"}}}, want: "mystery"}, }