@assistant-ui/react-generative-ui 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +5 -0
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +12 -3
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/renderGenerativeUI.js +2 -1
  6. package/dist/renderGenerativeUI.js.map +1 -1
  7. package/dist/slack/constants.d.ts +11 -4
  8. package/dist/slack/constants.d.ts.map +1 -1
  9. package/dist/slack/constants.js +11 -4
  10. package/dist/slack/constants.js.map +1 -1
  11. package/dist/slack/fromSlackBlocks.js +2 -2
  12. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  13. package/dist/slack/toSlackBlocks.js +114 -19
  14. package/dist/slack/toSlackBlocks.js.map +1 -1
  15. package/dist/slack/types.d.ts +7 -1
  16. package/dist/slack/types.d.ts.map +1 -1
  17. package/dist/teams/constants.d.ts +9 -2
  18. package/dist/teams/constants.d.ts.map +1 -1
  19. package/dist/teams/constants.js +10 -3
  20. package/dist/teams/constants.js.map +1 -1
  21. package/dist/teams/toAdaptiveCard.d.ts +10 -2
  22. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  23. package/dist/teams/toAdaptiveCard.js +54 -15
  24. package/dist/teams/toAdaptiveCard.js.map +1 -1
  25. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  26. package/dist/teams/toTeamsAttachments.js +21 -13
  27. package/dist/teams/toTeamsAttachments.js.map +1 -1
  28. package/dist/teams/types.d.ts +11 -2
  29. package/dist/teams/types.d.ts.map +1 -1
  30. package/dist/vocabulary/data.js +3 -2
  31. package/dist/vocabulary/data.js.map +1 -1
  32. package/dist/vocabulary/layout.js +2 -1
  33. package/dist/vocabulary/layout.js.map +1 -1
  34. package/package.json +5 -5
  35. package/src/JSONGenerativeUI.client.tsx +13 -5
  36. package/src/JSONGenerativeUI.test.tsx +21 -2
  37. package/src/slack/constants.ts +11 -3
  38. package/src/slack/toSlackBlocks.test.ts +374 -8
  39. package/src/slack/toSlackBlocks.ts +181 -24
  40. package/src/slack/types.ts +7 -1
  41. package/src/teams/constants.ts +10 -2
  42. package/src/teams/toAdaptiveCard.test.ts +204 -10
  43. package/src/teams/toAdaptiveCard.ts +79 -15
  44. package/src/teams/toTeamsAttachments.test.ts +47 -2
  45. package/src/teams/toTeamsAttachments.ts +17 -11
  46. package/src/teams/types.ts +11 -2
  47. package/src/vocabulary/layout.test.tsx +16 -1
  48. package/src/vocabulary/layout.tsx +4 -1
@@ -26,6 +26,7 @@ import {
26
26
  INPUT_LABEL_CAP,
27
27
  INTERACTIVE_TEXT_CAP,
28
28
  MARKDOWN_TEXT_BUDGET,
29
+ MAX_ELEMENT_DEPTH,
29
30
  MAX_TRAVERSAL_DEPTH,
30
31
  MESSAGE_BLOCK_CAP,
31
32
  MODAL_BLOCK_CAP,
@@ -73,6 +74,14 @@ const INTERACTIVE_TYPES = new Set([
73
74
  "RadioGroup",
74
75
  ]);
75
76
 
77
+ /**
78
+ * Types whose control disappears when a card is reshaped to text. `Input` and
79
+ * `Form` are not in {@link INTERACTIVE_TYPES} because they emit their own
80
+ * block rather than an actions element, but a reshape loses them the same way,
81
+ * and a `Form` gets a Submit button whether or not it carries an action.
82
+ */
83
+ const CONTROL_TYPES = new Set([...INTERACTIVE_TYPES, "Input", "Form"]);
84
+
76
85
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
77
86
 
78
87
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -201,6 +210,22 @@ const optionFrom = (
201
210
  };
202
211
  };
203
212
 
213
+ const warnDroppedOptions = (
214
+ kept: number,
215
+ taken: number,
216
+ component: string,
217
+ context: ConversionContext,
218
+ ) => {
219
+ const dropped = taken - kept;
220
+ if (dropped === 0) return;
221
+ warn(
222
+ context,
223
+ "dropped",
224
+ component,
225
+ `${dropped} ${dropped === 1 ? "option was" : "options were"} dropped for want of a string label and value.`,
226
+ );
227
+ };
228
+
204
229
  const toActionElement = (
205
230
  element: NormalizedUIElement,
206
231
  context: ConversionContext,
@@ -227,10 +252,16 @@ const toActionElement = (
227
252
  `options were clamped to ${SELECT_OPTION_CAP} entries.`,
228
253
  );
229
254
  }
230
- const options = rawOptions
231
- .slice(0, SELECT_OPTION_CAP)
255
+ const takenOptions = rawOptions.slice(0, SELECT_OPTION_CAP);
256
+ const options = takenOptions
232
257
  .map((option) => optionFrom(option, "Select", context))
233
258
  .filter((option): option is SlackOption => option !== undefined);
259
+ warnDroppedOptions(
260
+ options.length,
261
+ takenOptions.length,
262
+ "Select",
263
+ context,
264
+ );
234
265
  const placeholder = clampText(
235
266
  asString(props["placeholder"]),
236
267
  PLACEHOLDER_TEXT_CAP,
@@ -296,10 +327,16 @@ const toActionElement = (
296
327
  `options were clamped to ${RADIO_OPTION_CAP} entries.`,
297
328
  );
298
329
  }
299
- const options = rawOptions
300
- .slice(0, RADIO_OPTION_CAP)
330
+ const takenOptions = rawOptions.slice(0, RADIO_OPTION_CAP);
331
+ const options = takenOptions
301
332
  .map((option) => optionFrom(option, "RadioGroup", context))
302
333
  .filter((option): option is SlackOption => option !== undefined);
334
+ warnDroppedOptions(
335
+ options.length,
336
+ takenOptions.length,
337
+ "RadioGroup",
338
+ context,
339
+ );
303
340
  const selectedValue =
304
341
  typeof props["value"] === "string"
305
342
  ? props["value"]
@@ -582,10 +619,54 @@ const convertCard = (
582
619
  return [card];
583
620
  };
584
621
 
622
+ /** The content kinds a reshaped carousel card cannot carry, in report order. */
623
+ const LOST_CONTENT_KINDS = ["images", "tables", "charts", "controls"] as const;
624
+
625
+ const listPhrase = (items: readonly string[]): string =>
626
+ items.length <= 1
627
+ ? (items[0] ?? "")
628
+ : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
629
+
630
+ /**
631
+ * Collects the kinds of content a reshape drops, walking the same tree
632
+ * {@link collectText} does so a node nested below the top level counts too.
633
+ * These are exactly the pieces text cannot represent: an image has no text, a
634
+ * table and a chart carry theirs in array props, and a control is behavior,
635
+ * whether or not it carries an action of its own. An `$action` counts only on
636
+ * a node that renders a control from it, since a `Box` or a `Row` carrying one
637
+ * renders no control on the clean path either.
638
+ */
639
+ const scanLostContent = (
640
+ node: NormalizedUINode | undefined,
641
+ into: Set<string>,
642
+ depth: number,
643
+ ): void => {
644
+ if (depth > MAX_TRAVERSAL_DEPTH) return;
645
+ if (Array.isArray(node)) {
646
+ for (const child of node) scanLostContent(child, into, depth + 1);
647
+ return;
648
+ }
649
+ if (node === undefined || !isElement(node)) return;
650
+ if (
651
+ CONTROL_TYPES.has(node.type) ||
652
+ (node.type === "ListViewItem" && isRecord(node.action)) ||
653
+ (node.type === "Card" &&
654
+ (isRecord(node.props["confirm"]) || isRecord(node.props["cancel"])))
655
+ ) {
656
+ into.add("controls");
657
+ }
658
+ if (node.type === "Image") into.add("images");
659
+ if (node.type === "Table") into.add("tables");
660
+ if (node.type === "Chart") into.add("charts");
661
+ scanLostContent(node.children, into, depth + 1);
662
+ };
663
+
585
664
  /**
586
665
  * Degrades a card that cannot map cleanly into a title-and-body card block.
587
- * A carousel cannot fall back to a block sequence like a standalone card
588
- * can, so its content is clamped down to fit the card shape instead.
666
+ * A carousel cannot fall back to a block sequence like a standalone card can,
667
+ * so the card is reshaped to the two text fields the block has. Text reachable
668
+ * by {@link collectText} survives at any depth; an image, a table, a chart,
669
+ * and a control do not, and are reported separately from the reshape itself.
589
670
  */
590
671
  const degradeCard = (
591
672
  element: NormalizedUIElement,
@@ -596,14 +677,40 @@ const degradeCard = (
596
677
  const textChunks = collectText(element.children, depth + 1);
597
678
  const titleSource = rawTitle || textChunks[0] || "";
598
679
  const bodyChunks = rawTitle ? textChunks : textChunks.slice(1);
599
- const titleText = titleSource.slice(0, CARD_TITLE_CAP);
600
- const bodyText = bodyChunks.join("\n").slice(0, CARD_BODY_CAP);
680
+ const titleText = clampText(
681
+ titleSource,
682
+ CARD_TITLE_CAP,
683
+ "Card",
684
+ "title",
685
+ context,
686
+ );
687
+ const bodyText = clampText(
688
+ bodyChunks.join("\n"),
689
+ CARD_BODY_CAP,
690
+ "Card",
691
+ "body",
692
+ context,
693
+ );
601
694
  warn(
602
695
  context,
603
- "clamped",
696
+ "fallback",
604
697
  "Card",
605
- "A card inside a carousel was degraded to title and body.",
698
+ "A card inside a carousel was reshaped to title and body.",
606
699
  );
700
+ const lostKinds = new Set<string>();
701
+ if (isRecord(element.props["confirm"]) || isRecord(element.props["cancel"])) {
702
+ lostKinds.add("controls");
703
+ }
704
+ scanLostContent(element.children, lostKinds, depth + 1);
705
+ const lost = LOST_CONTENT_KINDS.filter((kind) => lostKinds.has(kind));
706
+ if (lost.length > 0) {
707
+ warn(
708
+ context,
709
+ "dropped",
710
+ "Card",
711
+ `A reshaped carousel card's ${listPhrase(lost)} were dropped.`,
712
+ );
713
+ }
607
714
  return buildCardBlock({
608
715
  ...(titleText ? { title: { type: "mrkdwn", text: titleText } } : {}),
609
716
  ...(bodyText ? { body: { type: "mrkdwn", text: bodyText } } : {}),
@@ -698,15 +805,47 @@ const convertListItem = (
698
805
  };
699
806
  };
700
807
 
808
+ /**
809
+ * Converts a child whose output is thrown away, and reports whether anything
810
+ * was lost with it. A scratch context keeps the throwaway out of the shared
811
+ * markdown and data-table budgets that surviving blocks still need; only its
812
+ * `dropped` warnings are forwarded, because those describe the tree the caller
813
+ * wrote, while a clamp or a fallback would describe content never delivered.
814
+ */
815
+ const discardedChild = (
816
+ child: NormalizedUINode,
817
+ context: ConversionContext,
818
+ depth: number,
819
+ ): boolean => {
820
+ const scratch: ConversionContext = { ...context, warnings: [] };
821
+ const produced = convertSequence(child, scratch, depth).length > 0;
822
+ const lost = scratch.warnings.filter((warning) => warning.code === "dropped");
823
+ context.warnings.push(...lost);
824
+ return produced || lost.length > 0;
825
+ };
826
+
701
827
  const convertListView = (
702
828
  element: NormalizedUIElement,
703
829
  context: ConversionContext,
704
830
  depth: number,
705
831
  ): SlackBlock[] => {
706
- const items = normalizedList(element.children).filter(
707
- (child): child is NormalizedUIElement =>
708
- isElement(child) && child.type === "ListViewItem",
709
- );
832
+ const items: NormalizedUIElement[] = [];
833
+ let discarded = 0;
834
+ for (const child of normalizedList(element.children)) {
835
+ if (isElement(child) && child.type === "ListViewItem") {
836
+ items.push(child);
837
+ continue;
838
+ }
839
+ if (discardedChild(child, context, depth + 1)) discarded += 1;
840
+ }
841
+ if (discarded > 0) {
842
+ warn(
843
+ context,
844
+ "dropped",
845
+ "ListView",
846
+ `${discarded} non-item ${discarded === 1 ? "child was" : "children were"} dropped.`,
847
+ );
848
+ }
710
849
  return items.flatMap((item, index) => [
711
850
  ...(index > 0 ? [{ type: "divider" as const }] : []),
712
851
  convertListItem(item, context, depth + 1),
@@ -719,12 +858,21 @@ const convertCarousel = (
719
858
  depth: number,
720
859
  ): SlackBlock[] => {
721
860
  const cardChildren: NormalizedUIElement[] = [];
861
+ let droppedCards = 0;
722
862
  for (const child of normalizedList(element.children)) {
723
863
  if (isElement(child) && child.type === "Card") {
724
864
  cardChildren.push(child);
725
- } else {
726
- warn(context, "dropped", "Carousel", "A non-card child was dropped.");
865
+ continue;
727
866
  }
867
+ if (discardedChild(child, context, depth + 1)) droppedCards += 1;
868
+ }
869
+ if (droppedCards > 0) {
870
+ warn(
871
+ context,
872
+ "dropped",
873
+ "Carousel",
874
+ `${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
875
+ );
728
876
  }
729
877
  if (cardChildren.length > CAROUSEL_CARD_CAP) {
730
878
  warn(
@@ -790,13 +938,22 @@ const convertTable = (
790
938
  );
791
939
  }
792
940
 
793
- const columnHeaderRow: SlackDataTableCell[] = rawColumns
794
- .slice(0, DATA_TABLE_COLUMN_CAP)
795
- .filter(isRecord)
796
- .map((column) => ({
797
- type: "raw_text" as const,
798
- text: asString(column["label"]),
799
- }));
941
+ const takenColumns = rawColumns.slice(0, DATA_TABLE_COLUMN_CAP);
942
+ const unlabeled = takenColumns.filter(
943
+ (column) => !isRecord(column) || typeof column["label"] !== "string",
944
+ ).length;
945
+ if (unlabeled > 0) {
946
+ warn(
947
+ context,
948
+ "dropped",
949
+ "Table",
950
+ `${unlabeled} column ${unlabeled === 1 ? "header was" : "headers were"} left blank for want of a string label.`,
951
+ );
952
+ }
953
+ const columnHeaderRow: SlackDataTableCell[] = takenColumns.map((column) => ({
954
+ type: "raw_text" as const,
955
+ text: isRecord(column) ? asString(column["label"]) : "",
956
+ }));
800
957
  const dataRows: SlackDataTableCell[][] = rawRows
801
958
  .slice(0, DATA_TABLE_ROW_CAP)
802
959
  .map((row) =>
@@ -1283,7 +1440,7 @@ export function toSlackBlocks(
1283
1440
  : reason === "cycle"
1284
1441
  ? "a self-referencing node was dropped."
1285
1442
  : reason === "depth"
1286
- ? `nodes deeper than ${MAX_TRAVERSAL_DEPTH} levels were dropped.`
1443
+ ? `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`
1287
1444
  : `children were clamped to ${CHILDREN_CAP} entries.`;
1288
1445
  warn(context, "clamped", "Root", detail);
1289
1446
  });
@@ -210,7 +210,13 @@ export interface ToSlackBlocksOptions {
210
210
  readonly surface?: "message" | "modal";
211
211
  }
212
212
 
213
- /** A non-fatal downgrade reported during Slack conversion. */
213
+ /**
214
+ * A non-fatal note reported during Slack conversion.
215
+ *
216
+ * `clamped` removed content to fit a limit, `dropped` discarded a node or one
217
+ * of its props and may leave a placeholder note behind, and `fallback` emitted
218
+ * the node in a different form than requested.
219
+ */
214
220
  export interface SlackConversionWarning {
215
221
  readonly code: "clamped" | "dropped" | "fallback";
216
222
  readonly component: string;
@@ -17,9 +17,17 @@ export const ADAPTIVE_CARD_SCHEMA =
17
17
  export const ATTACHMENT_CONTENT_TYPE =
18
18
  "application/vnd.microsoft.card.adaptive";
19
19
 
20
- /** The maximum normalized traversal depth. */
20
+ /** The maximum traversal depth, counted in visited values, not tree levels. */
21
21
  export const MAX_TRAVERSAL_DEPTH = 64;
22
22
 
23
+ /**
24
+ * The element-nesting ceiling a caller observes. `boundNode` visits an element
25
+ * and its `children` array separately, so one level of nesting spends two of
26
+ * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
27
+ * the looser of the two. A root array costs one more level than a root object.
28
+ */
29
+ export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
30
+
23
31
  /**
24
32
  * The maximum number of entries kept at any single level (root, or a
25
33
  * `children` array at any depth) of the raw spec, before it ever reaches
@@ -46,7 +54,7 @@ export function clampReasonDetail(reason: ClampReason): string {
46
54
  }
47
55
  if (reason === "cycle") return "a self-referencing node was dropped.";
48
56
  if (reason === "depth") {
49
- return `nodes deeper than ${MAX_TRAVERSAL_DEPTH} levels were dropped.`;
57
+ return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;
50
58
  }
51
59
  return `children were clamped to ${CHILDREN_CAP} entries.`;
52
60
  }
@@ -368,7 +368,7 @@ describe("toAdaptiveCard", () => {
368
368
  expect(action.mode).toBe("secondary");
369
369
  }
370
370
  expect(warnings).toContainEqual(
371
- expect.objectContaining({ code: "clamped", component: "Button" }),
371
+ expect.objectContaining({ code: "fallback", component: "Button" }),
372
372
  );
373
373
  });
374
374
  });
@@ -418,7 +418,7 @@ describe("toAdaptiveCard", () => {
418
418
  expect(action.mode).toBe("secondary");
419
419
  }
420
420
  expect(warnings).toContainEqual(
421
- expect.objectContaining({ code: "clamped", component: "Button" }),
421
+ expect.objectContaining({ code: "fallback", component: "Button" }),
422
422
  );
423
423
  });
424
424
  });
@@ -623,7 +623,7 @@ describe("toAdaptiveCard", () => {
623
623
  const element = card.body[0] as { id: string };
624
624
  expect(element.id).toBe("aui_");
625
625
  expect(warnings).toContainEqual(
626
- expect.objectContaining({ code: "clamped", component: type }),
626
+ expect.objectContaining({ code: "fallback", component: type }),
627
627
  );
628
628
  },
629
629
  );
@@ -647,7 +647,7 @@ describe("toAdaptiveCard", () => {
647
647
  const ids = (card.body as TeamsInputText[]).map((element) => element.id);
648
648
  expect(ids).toEqual(["input", "input_2"]);
649
649
  expect(warnings).toContainEqual(
650
- expect.objectContaining({ code: "clamped", component: "Input" }),
650
+ expect.objectContaining({ code: "fallback", component: "Input" }),
651
651
  );
652
652
  });
653
653
 
@@ -659,7 +659,9 @@ describe("toAdaptiveCard", () => {
659
659
  ]);
660
660
  const ids = (card.body as TeamsInputText[]).map((element) => element.id);
661
661
  expect(new Set(ids).size).toBe(3);
662
- expect(warnings.some((warning) => warning.code === "clamped")).toBe(true);
662
+ expect(warnings.some((warning) => warning.code === "fallback")).toBe(
663
+ true,
664
+ );
663
665
  });
664
666
 
665
667
  it("names the actually-emitted id in the reserved-key warning, and fires only that one warning, when the rename target also collides", () => {
@@ -671,7 +673,7 @@ describe("toAdaptiveCard", () => {
671
673
  expect(ids).toEqual(["aui_", "aui__2"]);
672
674
  expect(warnings).toEqual([
673
675
  {
674
- code: "clamped",
676
+ code: "fallback",
675
677
  component: "Input",
676
678
  detail:
677
679
  'the input id "aui" collides with the submit envelope\'s reserved key and was renamed to "aui__2".',
@@ -801,7 +803,7 @@ describe("toAdaptiveCard", () => {
801
803
  const columnSet = card.body[0] as TeamsColumnSet;
802
804
  expect(columnSet.columns).toHaveLength(4);
803
805
  expect(warnings).toContainEqual(
804
- expect.objectContaining({ code: "clamped", component: "Row" }),
806
+ expect.objectContaining({ code: "advisory", component: "Row" }),
805
807
  );
806
808
  });
807
809
  });
@@ -863,9 +865,177 @@ describe("toAdaptiveCard", () => {
863
865
  detail: "Unknown component type was dropped.",
864
866
  });
865
867
  });
868
+
869
+ it("keeps a discarded child's clamp warning out of the result", () => {
870
+ const { warnings } = toAdaptiveCard({
871
+ $type: "ListView",
872
+ children: [
873
+ {
874
+ $type: "Table",
875
+ rows: Array.from({ length: TABLE_ROW_CAP + 5 }, () => ["x"]),
876
+ },
877
+ { $type: "ListViewItem", title: "row" },
878
+ ],
879
+ });
880
+ expect(warnings.some((warning) => warning.code === "clamped")).toBe(
881
+ false,
882
+ );
883
+ expect(warnings).toContainEqual({
884
+ code: "dropped",
885
+ component: "ListView",
886
+ detail: "1 non-item child was dropped.",
887
+ });
888
+ });
889
+
890
+ it("reports a renderable non-item child whose output it discards", () => {
891
+ const { card, warnings } = toAdaptiveCard({
892
+ $type: "ListView",
893
+ children: [
894
+ { $type: "Text", value: "stray" },
895
+ { $type: "ListViewItem", children: { $type: "Text", value: "Kept" } },
896
+ ],
897
+ });
898
+ expect(card.body).toEqual([
899
+ {
900
+ type: "Container",
901
+ items: [{ type: "TextBlock", text: "Kept", wrap: true }],
902
+ },
903
+ ]);
904
+ expect(warnings).toContainEqual({
905
+ code: "dropped",
906
+ component: "ListView",
907
+ detail: "1 non-item child was dropped.",
908
+ });
909
+ });
910
+
911
+ it.each([
912
+ ["before", 0],
913
+ ["after", 1],
914
+ ] as const)(
915
+ "does not let a discarded non-item child claim or report an input id, surviving control %s",
916
+ (_position, listViewIndex) => {
917
+ const listView = {
918
+ $type: "ListView",
919
+ children: [
920
+ { $type: "Input", name: "email" },
921
+ { $type: "ListViewItem", title: "row" },
922
+ ],
923
+ };
924
+ const survivor = { $type: "Input", name: "email", label: "Real" };
925
+ const { card, warnings } = toAdaptiveCard(
926
+ listViewIndex === 0 ? [listView, survivor] : [survivor, listView],
927
+ );
928
+ const ids = card.body.map((element) => (element as { id?: string }).id);
929
+ expect(ids).toContain("email");
930
+ expect(
931
+ warnings.some((warning) => warning.detail.includes("email_2")),
932
+ ).toBe(false);
933
+ },
934
+ );
935
+ });
936
+
937
+ describe("Carousel", () => {
938
+ it("reports the non-card children a nested carousel filters out", () => {
939
+ const { warnings } = toAdaptiveCard({
940
+ $type: "Col",
941
+ children: {
942
+ $type: "Carousel",
943
+ children: [
944
+ { $type: "Text", value: "lost" },
945
+ { $type: "Card", title: "kept" },
946
+ ],
947
+ },
948
+ });
949
+ expect(warnings).toContainEqual({
950
+ code: "dropped",
951
+ component: "Carousel",
952
+ detail: "1 non-card child was dropped.",
953
+ });
954
+ });
955
+
956
+ it("stays silent for a nested-carousel child that renders nothing anyway", () => {
957
+ const { warnings } = toAdaptiveCard({
958
+ $type: "Col",
959
+ children: {
960
+ $type: "Carousel",
961
+ children: [{ $type: "Spacer" }, { $type: "Card", title: "kept" }],
962
+ },
963
+ });
964
+ expect(warnings.some((warning) => warning.code === "dropped")).toBe(
965
+ false,
966
+ );
967
+ });
968
+
969
+ it("forwards a nested-carousel child's own dropped warning", () => {
970
+ const { warnings } = toAdaptiveCard({
971
+ $type: "Col",
972
+ children: {
973
+ $type: "Carousel",
974
+ children: [{ $type: "Mystery" }, { $type: "Card", title: "kept" }],
975
+ },
976
+ });
977
+ expect(warnings).toContainEqual({
978
+ code: "dropped",
979
+ component: "Mystery",
980
+ detail: "Unknown component type was dropped.",
981
+ });
982
+ });
983
+ });
984
+
985
+ describe("choices", () => {
986
+ it("reports options dropped for want of a string value", () => {
987
+ const { card, warnings } = toAdaptiveCard({
988
+ $type: "Select",
989
+ name: "s",
990
+ options: [{ label: "ok", value: "a" }, { label: "bad" }, "nope"],
991
+ });
992
+ expect((card.body[0] as { choices: unknown[] }).choices).toEqual([
993
+ { title: "ok", value: "a" },
994
+ ]);
995
+ expect(warnings).toContainEqual({
996
+ code: "dropped",
997
+ component: "Select",
998
+ detail: "2 options were dropped for want of a string value.",
999
+ });
1000
+ });
1001
+
1002
+ it("names RadioGroup when the same loss happens there", () => {
1003
+ const { warnings } = toAdaptiveCard({
1004
+ $type: "RadioGroup",
1005
+ name: "r",
1006
+ options: [{ label: "ok", value: "a" }, { label: "bad" }],
1007
+ });
1008
+ expect(warnings).toContainEqual({
1009
+ code: "dropped",
1010
+ component: "RadioGroup",
1011
+ detail: "1 option was dropped for want of a string value.",
1012
+ });
1013
+ });
866
1014
  });
867
1015
 
868
1016
  describe("Table", () => {
1017
+ it.each([["A"], [{}], [{ label: 42 }]])(
1018
+ "keeps an unlabeled column's position in the header and reports it: %j",
1019
+ (column) => {
1020
+ const { card, warnings } = toAdaptiveCard({
1021
+ $type: "Table",
1022
+ columns: [column, { label: "B" }],
1023
+ rows: [["1", "2"]],
1024
+ });
1025
+ const table = card.body[0] as TeamsTable;
1026
+ expect(
1027
+ table.rows[0]?.cells.map(
1028
+ (cell) => (cell.items[0] as TeamsTextBlock).text,
1029
+ ),
1030
+ ).toEqual(["", "B"]);
1031
+ expect(warnings).toContainEqual({
1032
+ code: "dropped",
1033
+ component: "Table",
1034
+ detail: "1 column header was left blank for want of a string label.",
1035
+ });
1036
+ },
1037
+ );
1038
+
869
1039
  it("renders firstRowAsHeaders true and a header row when columns are present", () => {
870
1040
  const { card } = toAdaptiveCard({
871
1041
  $type: "Table",
@@ -1158,7 +1328,13 @@ describe("toAdaptiveCard", () => {
1158
1328
  const value = "x".repeat(PAYLOAD_SOFT_CAP + 1000);
1159
1329
  const { warnings } = toAdaptiveCard({ $type: "Text", value });
1160
1330
  expect(warnings).toContainEqual(
1161
- expect.objectContaining({ code: "clamped", component: "Root" }),
1331
+ expect.objectContaining({
1332
+ code: "advisory",
1333
+ component: "Root",
1334
+ detail: expect.stringContaining(
1335
+ `over the ${PAYLOAD_SOFT_CAP}-byte soft budget`,
1336
+ ),
1337
+ }),
1162
1338
  );
1163
1339
  });
1164
1340
 
@@ -1166,7 +1342,7 @@ describe("toAdaptiveCard", () => {
1166
1342
  const value = "汉".repeat(30000);
1167
1343
  const { warnings } = toAdaptiveCard({ $type: "Markdown", value });
1168
1344
  expect(warnings).toContainEqual(
1169
- expect.objectContaining({ code: "clamped", component: "Root" }),
1345
+ expect.objectContaining({ code: "advisory", component: "Root" }),
1170
1346
  );
1171
1347
  });
1172
1348
 
@@ -1295,9 +1471,27 @@ describe("toAdaptiveCard", () => {
1295
1471
  expect(warnings).toContainEqual({
1296
1472
  code: "clamped",
1297
1473
  component: "Root",
1298
- detail: "nodes deeper than 64 levels were dropped.",
1474
+ detail: "nodes deeper than 32 levels were dropped.",
1299
1475
  });
1300
1476
  });
1477
+
1478
+ it("reports the depth detail at the level it actually starts dropping", () => {
1479
+ const chain = (levels: number) => {
1480
+ let node: unknown = { $type: "Caption", value: "x" };
1481
+ for (let i = 0; i < levels; i++) {
1482
+ node = { $type: "Card", children: [node] };
1483
+ }
1484
+ return node;
1485
+ };
1486
+ const dropped = (node: unknown) =>
1487
+ toAdaptiveCard(node).warnings.some((warning) =>
1488
+ warning.detail.includes("deeper than"),
1489
+ );
1490
+ expect(dropped(chain(32))).toBe(false);
1491
+ expect(dropped(chain(33))).toBe(true);
1492
+ expect(dropped([chain(31)])).toBe(false);
1493
+ expect(dropped([chain(32)])).toBe(true);
1494
+ });
1301
1495
  });
1302
1496
  });
1303
1497