Files
openclaw/extensions/line/src/message-cards.test.ts
Harjoth Khara eac3e08cfd fix(line): cap carousel column text at 60 chars when a title or image is set (#93429)
* fix(line): cap carousel column text at 60 chars with title or image

LINE limits a carousel column's text to 60 characters when the column has
a title or thumbnail image, and 120 characters otherwise. createCarouselColumn
always truncated to 120, so a column with a title/image and 61-120 char text
exceeded the limit and made LINE reject the entire carousel reply (HTTP 400).
Apply the conditional limit (mirroring the buttons template) and drop the now
redundant slice in createProductCarousel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(line): apply conditional text limits across templates

* fix(line): truncate template text by code point

* fix(line): preserve grapheme clusters when truncating

* fix(line): apply compact limit for default actions

* fix(line): follow title and thumbnail text limits

* fix(line): truncate template text within UTF-16 limits

* fix(line): preserve required text within template limits

* fix(line): preserve carousel product prices

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:23:05 +08:00

275 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Line tests cover message cards plugin behavior.
import { describe, expect, it } from "vitest";
import {
createActionCard,
createCarousel,
createDeviceControlCard,
createEventCard,
createImageCard,
createInfoCard,
createListCard,
} from "./flex-templates.js";
import {
createConfirmTemplate,
createButtonTemplate,
createTemplateCarousel,
createCarouselColumn,
createImageCarousel,
createImageCarouselColumn,
createProductCarousel,
messageAction,
} from "./template-messages.js";
describe("createConfirmTemplate", () => {
it("truncates text to 240 characters", () => {
const longText = "x".repeat(300);
const template = createConfirmTemplate(longText, messageAction("Yes"), messageAction("No"));
expect((template.template as { text: string }).text.length).toBe(240);
});
});
describe("createButtonTemplate", () => {
it("limits actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => messageAction(`Button ${i}`));
const template = createButtonTemplate("Title", "Text", actions);
expect((template.template as { actions: unknown[] }).actions.length).toBe(4);
});
it("truncates title to 40 characters", () => {
const longTitle = "x".repeat(50);
const template = createButtonTemplate(longTitle, "Text", [messageAction("OK")]);
expect((template.template as { title: string }).title.length).toBe(40);
});
it("truncates text to 60 chars when no thumbnail is provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);
expect((template.template as { text: string }).text.length).toBe(60);
});
it("truncates text to 60 chars when title and thumbnail are provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
thumbnailImageUrl: "https://example.com/thumb.jpg",
});
expect((template.template as { text: string }).text.length).toBe(60);
});
});
describe("createCarouselColumn", () => {
it("limits actions to 3", () => {
const column = createCarouselColumn({
text: "Text",
actions: [
messageAction("A1"),
messageAction("A2"),
messageAction("A3"),
messageAction("A4"),
messageAction("A5"),
],
});
expect(column.actions.length).toBe(3);
});
it("truncates text to 120 characters when no title or image is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
expect(column.text.length).toBe(120);
});
it("truncates text to 60 characters when a title is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
title: "Title",
text: longText,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
const text = `${"x".repeat(59)}👨👩👧👦after`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(59));
});
it("keeps required text when the first grapheme exceeds the limit", () => {
const text = `😀${"\u0301".repeat(59)}`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
expect(column.text.startsWith("😀")).toBe(true);
});
it("uses the compact limit when a whitespace-only title is present", () => {
const column = createCarouselColumn({
title: " ",
text: "x".repeat(150),
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(60));
});
it("truncates text to 60 characters when a thumbnail image is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
text: longText,
thumbnailImageUrl: "https://example.com/thumb.jpg",
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
});
describe("carousel column limits", () => {
it.each([
{
createTemplate: () =>
createTemplateCarousel(
Array.from({ length: 15 }, () =>
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
),
),
},
{
createTemplate: () =>
createImageCarousel(
Array.from({ length: 15 }, (_, i) =>
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
),
),
},
])("limits columns to 10", ({ createTemplate }) => {
const template = createTemplate();
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
});
});
describe("createProductCarousel", () => {
it.each([
{
title: "Product",
description: "Desc",
actionLabel: "Buy",
actionUrl: "https://shop.com/buy",
expectedType: "uri",
},
{
title: "Product",
description: "Desc",
actionLabel: "Select",
actionData: "product_id=123",
expectedType: "postback",
},
])("uses expected action type for product action", ({ expectedType, ...item }) => {
const template = createProductCarousel([item]);
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
.columns;
expect(columns[0].actions[0].type).toBe(expectedType);
});
it("preserves the complete price when truncating a long description", () => {
const template = createProductCarousel([
{
title: "Product",
description: "x".repeat(59),
price: "$12.99",
},
]);
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
expect(columns[0].text.length).toBe(60);
});
});
describe("flex cards", () => {
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
it("limits list items to 8", () => {
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
const card = createListCard("List", items);
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
it("includes image-card body text when provided", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
const body = card.body as { contents: Array<{ text: string }> };
expect(body.contents.length).toBe(2);
expect(body.contents[1].text).toBe("Body text");
});
it("limits action-card actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => ({
label: `Action ${i}`,
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
}));
const card = createActionCard("Title", "Body", actions);
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBe(4);
});
it("limits carousels to 12 bubbles", () => {
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
const carousel = createCarousel(bubbles);
expect(carousel.contents.length).toBe(12);
});
it("limits device controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
it("keeps event-card optional fields together", () => {
const card = createEventCard({
title: "Team Offsite",
date: "February 15, 2026",
time: "9:00 AM - 5:00 PM",
location: "Mountain View Office",
description: "Annual team building event",
});
expect(card.size).toBe("mega");
const body = card.body as { contents: Array<{ type: string }> };
expect(body.contents).toHaveLength(3);
});
});