From 2f35ce37247b01a47cb77b5c090dc24ffbc5e466 Mon Sep 17 00:00:00 2001 From: 91-enjoy Date: Wed, 3 Jun 2026 19:17:12 +0800 Subject: [PATCH] feat: complete card message format (#1198) The card message converter (shortcuts/im/convert_lib/card.go) previously rendered a subset of card fields and had several mode-gated behaviors that caused information to be silently dropped in concise mode. This PR audits every element handler and brings the output up to full fidelity: missing header fields are rendered, collapsible panels always expand, rich element metadata (images, audio, video, overflow URLs, person names) is no longer hidden behind cardModeDetailed, and several format bugs are fixed. Change-Id: I422474ab6b7505e48ab5697793900df035be6e29 --- shortcuts/im/convert_lib/card.go | 443 ++++++++--- shortcuts/im/convert_lib/card_test.go | 1011 ++++++++++++++++++++++++- 2 files changed, 1314 insertions(+), 140 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,27 +916,7 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { } disabled, _ := prop["disabled"].(bool) - if disabled && c.mode == cardModeConcise { - return fmt.Sprintf("[%s โœ—]", buttonText) - } - - if actions, ok := prop["actions"].([]interface{}); ok { - for _, action := range actions { - am, ok := action.(cardObj) - if !ok { - continue - } - 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) - } - } - } - } - } - - if disabled && c.mode == cardModeDetailed { + if disabled { result := fmt.Sprintf("[%s โœ—]", buttonText) if tips, ok := prop["disabledTips"].(cardObj); ok { if tipsText := c.extractTextContent(tips); tipsText != "" { @@ -882,7 +926,42 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string { return result } - return fmt.Sprintf("[%s]", buttonText) + result := fmt.Sprintf("[%s]", buttonText) + if actions, ok := prop["actions"].([]interface{}); ok { + for _, action := range actions { + am, ok := action.(cardObj) + if !ok { + continue + } + if am["type"] == "open_url" { + if ad, ok := am["action"].(cardObj); ok { + if urlStr, ok := ad["url"].(string); ok && urlStr != "" { + result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + break + } + } + } + } + } + + 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) + } + } + 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 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"}, }